From 8a1fe18b73c241d4bbd93e19b074759da312d76c Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sat, 25 Apr 2026 00:25:38 -0700 Subject: [PATCH 01/22] #61 Smart Linux WIP --- src/jdiskmark/App.java | 14 + src/jdiskmark/BenchmarkRunner.java | 43 ++ src/jdiskmark/Gui.java | 1 + src/jdiskmark/MainFrame.form | 9 + src/jdiskmark/MainFrame.java | 48 ++ src/jdiskmark/Smart.java | 678 +++++++++++++++++++++++++++++ src/jdiskmark/SmartPanel.java | 360 +++++++++++++++ 7 files changed, 1153 insertions(+) create mode 100644 src/jdiskmark/Smart.java create mode 100644 src/jdiskmark/SmartPanel.java diff --git a/src/jdiskmark/App.java b/src/jdiskmark/App.java index 1cf29c1..90884e1 100644 --- a/src/jdiskmark/App.java +++ b/src/jdiskmark/App.java @@ -287,6 +287,7 @@ public static void loadProfile(BenchmarkProfile profile) { writeSyncEnable = profile.isWriteSyncEnable(); sectorAlignment = profile.getSectorAlignment(); multiFile = profile.isMultiFile(); +// Smart.enableSmart = profile.getEnableSmart(); } finally { saveConfig(); } @@ -339,6 +340,9 @@ public static void loadConfig() { value = p.getProperty("multiFile", String.valueOf(multiFile)); multiFile = Boolean.parseBoolean(value); + + value = p.getProperty("enableSmart", String.valueOf(Smart.enableSmart)); + Smart.enableSmart = Boolean.parseBoolean(value); value = p.getProperty("autoRemoveData", String.valueOf(autoRemoveData)); autoRemoveData = Boolean.parseBoolean(value); @@ -430,6 +434,7 @@ public static void saveConfig() { p.setProperty("profileModified", String.valueOf(profileModified)); p.setProperty("benchmarkType", benchmarkType.name()); p.setProperty("multiFile", String.valueOf(multiFile)); + p.setProperty("enableSmart", String.valueOf(Smart.enableSmart)); p.setProperty("autoRemoveData", String.valueOf(autoRemoveData)); p.setProperty("autoReset", String.valueOf(autoReset)); p.setProperty("blockSequence", blockSequence.name()); @@ -482,6 +487,7 @@ public static BenchmarkConfig getConfig() { config.gcRetryEnabled = GcDetector.gcRetryEnabled; config.gcHintsEnabled = GcDetector.gcHintsEnabled; config.multiFileEnabled = multiFile; +// config.enabledSmart = Smart.enableSmart; --- TODO in config --- config.testDir = dataDir.getAbsolutePath(); return config; } @@ -501,6 +507,7 @@ public static String getConfigString() { sb.append("writeTest: ").append(hasWriteOperation()).append('\n'); sb.append("locationDir: ").append(locationDir).append('\n'); sb.append("multiFile: ").append(multiFile).append('\n'); + sb.append("autoRemoveData: ").append(autoRemoveData).append('\n'); sb.append("autoReset: ").append(autoReset).append('\n'); sb.append("blockSequence: ").append(blockSequence).append('\n'); @@ -811,4 +818,11 @@ static public void setLocationDir(File directory) { locationDir = directory; dataDir = new File (locationDir.getAbsolutePath() + File.separator + DATADIRNAME); } + + static public boolean isLinux() { + if (os == null) { + os = System.getProperty("os.name"); + } + return os.contains("Linux"); + } } diff --git a/src/jdiskmark/BenchmarkRunner.java b/src/jdiskmark/BenchmarkRunner.java index e41c731..c2f7405 100644 --- a/src/jdiskmark/BenchmarkRunner.java +++ b/src/jdiskmark/BenchmarkRunner.java @@ -1,5 +1,7 @@ package jdiskmark; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import static jdiskmark.GcDetector.MAX_GC_RETRIES; import java.util.logging.Level; @@ -119,6 +121,47 @@ public Benchmark execute() throws Exception { GcDetector.triggerAndWait(); // Initial cleanup } + if (Smart.enableSmart && App.isLinux()) { +// privilege escallation + String testDir = config.getTestDir(); + String partition = UtilOs.getPartitionFromFilePathLinux(Path.of(testDir)); + String deviceName = UtilOs.getDeviceNamesFromPartitionLinux(partition).get(0); + +// System.out.println(deviceName); + ProcessBuilder pb = new ProcessBuilder("pkexec", "/usr/sbin/smartctl", "--json", "-a", "/dev/" + deviceName); + Process process = pb.start(); + + String result = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + Smart smart = Smart.fromJson(result); + logger.log(Level.INFO, "SMART model : {0}", smart.getModelName()); + logger.log(Level.INFO, "SMART serial : {0}", smart.getSerialNumber()); + logger.log(Level.INFO, "SMART firmware : {0}", smart.getFirmwareVersion()); + if (smart.getSmartStatus() != null) { + logger.log(Level.INFO, "SMART status : {0}", smart.getSmartStatus().isPassed() ? "PASSED" : "FAILED"); + } + if (smart.getTemperature() != null) { + logger.log(Level.INFO, "SMART temp : {0} C", smart.getTemperature().getCurrent()); + } + if (smart.getPowerOnTime() != null) { + logger.log(Level.INFO, "SMART power-on : {0} hours", smart.getPowerOnTime().getHours()); + } + if (smart.getNvmeHealthLog() != null) { + Smart.NvmeHealthLog nvme = smart.getNvmeHealthLog(); + logger.log(Level.INFO, "NVMe avail spare : {0}%", nvme.getAvailableSpare()); + logger.log(Level.INFO, "NVMe used % : {0}%", nvme.getPercentageUsed()); + logger.log(Level.INFO, "NVMe written : {0} GB", nvme.getDataWrittenGb()); + logger.log(Level.INFO, "NVMe read : {0} GB", nvme.getDataReadGb()); + logger.log(Level.INFO, "NVMe media errs : {0}", nvme.getMediaErrors()); + if (nvme.hasCriticalWarning()) { + logger.log(Level.WARNING, "NVMe critical warning flag: {0}", nvme.getCriticalWarning()); + } + } + // Populate the SMART tab in the GUI + if (Gui.smartPanel != null) { + Gui.smartPanel.populate(smart); + } + } + benchmark.recordStartTime(); // Execution Loops diff --git a/src/jdiskmark/Gui.java b/src/jdiskmark/Gui.java index 1646a0b..968be20 100644 --- a/src/jdiskmark/Gui.java +++ b/src/jdiskmark/Gui.java @@ -90,6 +90,7 @@ public String toString() { public static BenchmarkControlPanel controlPanel = null; public static SelectDriveFrame selFrame = null; public static BenchmarkPanel runPanel = null; + public static SmartPanel smartPanel = null; public static JProgressBar progressBar = null; // graph component public static JFreeChart chart; diff --git a/src/jdiskmark/MainFrame.form b/src/jdiskmark/MainFrame.form index bf7d334..6bec6ba 100644 --- a/src/jdiskmark/MainFrame.form +++ b/src/jdiskmark/MainFrame.form @@ -276,6 +276,15 @@ + + + + + + + + + diff --git a/src/jdiskmark/MainFrame.java b/src/jdiskmark/MainFrame.java index c044a9e..2d3242c 100644 --- a/src/jdiskmark/MainFrame.java +++ b/src/jdiskmark/MainFrame.java @@ -36,6 +36,7 @@ public MainFrame() { cResultMountPanel.setLayout(new BorderLayout()); Gui.chartPanel.setSize(cResultMountPanel.getSize()); Gui.chartPanel.setSize(cResultMountPanel.getWidth(), 200); + Gui.smartPanel = new SmartPanel(); cResultMountPanel.add(Gui.chartPanel); BenchmarkControlPanel bcPanel = Gui.createControlPanel(); bControlMountPanel.setLayout(new MigLayout()); @@ -69,8 +70,38 @@ public MainFrame() { // auto scroll the text area. DefaultCaret caret = (DefaultCaret)msgTextArea.getCaret(); caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE); + + + if (App.isLinux()) { + // Build new left-side main navigation tab pane wrapping the top content area. + // The Benchmark tab contains the control panel (left) + chart (right). + // The bottom tabbedPane (Benchmark Operations / Events / Drive Location) stays below. + javax.swing.JTabbedPane mainTabPane = new javax.swing.JTabbedPane(javax.swing.JTabbedPane.LEFT); + mainTabPane.putClientProperty("JTabbedPane.tabRotation", "auto"); + + JPanel benchTab = new JPanel(new BorderLayout()); + benchTab.add(bControlMountPanel, BorderLayout.WEST); + benchTab.add(cResultMountPanel, BorderLayout.CENTER); + mainTabPane.addTab("Benchmark", benchTab); + + // SMART tab placeholder — ready for SMART data panel + mainTabPane.addTab("SMART", Gui.smartPanel); + + // Rebuild the content pane: mainTabPane fills the center; + // the original bottom tabs + progress bar go in a south panel. + getContentPane().removeAll(); + getContentPane().setLayout(new BorderLayout()); + getContentPane().add(mainTabPane, BorderLayout.CENTER); + + JPanel southPanel = new JPanel(new BorderLayout()); + southPanel.add(tabbedPane, BorderLayout.CENTER); + southPanel.add(progressPanel, BorderLayout.SOUTH); + getContentPane().add(southPanel, BorderLayout.SOUTH); + } } + + public JPanel getMountPanel() { return cResultMountPanel; } @@ -231,6 +262,7 @@ private void initComponents() { align16KRbMenuItem = new javax.swing.JRadioButtonMenuItem(); align64KRbMenuItem = new javax.swing.JRadioButtonMenuItem(); multiFileCheckBoxMenuItem = new javax.swing.JCheckBoxMenuItem(); + smartCbMenuItem = new javax.swing.JCheckBoxMenuItem(); jSeparator4 = new javax.swing.JPopupMenu.Separator(); gcHintsCbMenuItem = new javax.swing.JCheckBoxMenuItem(); gcRetryCbMenuItem = new javax.swing.JCheckBoxMenuItem(); @@ -586,6 +618,15 @@ public void actionPerformed(java.awt.event.ActionEvent evt) { } }); optionMenu.add(multiFileCheckBoxMenuItem); + + smartCbMenuItem.setSelected(true); + smartCbMenuItem.setText("S.M.A.R.T."); + smartCbMenuItem.addActionListener(new java.awt.event.ActionListener() { + public void actionPerformed(java.awt.event.ActionEvent evt) { + smartCbMenuItemActionPerformed(evt); + } + }); + optionMenu.add(smartCbMenuItem); optionMenu.add(jSeparator4); gcHintsCbMenuItem.setText("GC Hint Optimizing"); @@ -1088,6 +1129,12 @@ private void httpsProtoRbMenuItemActionPerformed(java.awt.event.ActionEvent evt) App.saveConfig(); }//GEN-LAST:event_httpsProtoRbMenuItemActionPerformed + private void smartCbMenuItemActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_smartCbMenuItemActionPerformed + Smart.enableSmart = this.smartCbMenuItem.isSelected(); + App.saveConfig(); + + }//GEN-LAST:event_smartCbMenuItemActionPerformed + // Variables declaration - do not modify//GEN-BEGIN:variables private javax.swing.JMenu actionMenu; private javax.swing.JRadioButtonMenuItem align16KRbMenuItem; @@ -1162,6 +1209,7 @@ private void httpsProtoRbMenuItemActionPerformed(java.awt.event.ActionEvent evt) private javax.swing.JCheckBoxMenuItem showAccessCheckBoxMenuItem; private javax.swing.JCheckBoxMenuItem showMaxMinCheckBoxMenuItem; private javax.swing.JCheckBoxMenuItem showSingleOpMenuItem; + private javax.swing.JCheckBoxMenuItem smartCbMenuItem; private javax.swing.JTabbedPane tabbedPane; private javax.swing.JRadioButtonMenuItem testEndpointRbMenuItem; private javax.swing.ButtonGroup themeButtonGroup; diff --git a/src/jdiskmark/Smart.java b/src/jdiskmark/Smart.java new file mode 100644 index 0000000..2161ce4 --- /dev/null +++ b/src/jdiskmark/Smart.java @@ -0,0 +1,678 @@ +package jdiskmark; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.List; + +/** + * Parses the JSON output of {@code smartctl --json -a /dev/<device>}. + * + *

Top-level sections covered: + *

    + *
  • {@code smartctl} – tool version / exit status
  • + *
  • {@code device} – device name, type, protocol
  • + *
  • {@code model_family} – drive family string (SATA only)
  • + *
  • {@code model_name} – drive model
  • + *
  • {@code serial_number} – serial number
  • + *
  • {@code firmware_version} – firmware version string
  • + *
  • {@code user_capacity} – drive capacity
  • + *
  • {@code smart_status} – overall SMART passed/failed
  • + *
  • {@code temperature} – current / drive-trip temps
  • + *
  • {@code power_on_time} – hours powered on
  • + *
  • {@code power_cycle_count}– number of power cycles
  • + *
  • {@code ata_smart_attributes} – classical ATA SMART attributes table
  • + *
  • {@code nvme_smart_health_information_log} – NVMe health log
  • + *
+ * + *

Usage example: + *

{@code
+ *   String json = ...; // output from smartctl --json -a /dev/nvme0n1
+ *   Smart data = Smart.fromJson(json);
+ *   System.out.println(data.getModelName());
+ *   System.out.println(data.getSmartStatus().isPassed());
+ * }
+ * + * @author jasmine + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class Smart { + + // ------------------------------------------------------------------------- + // Feature toggle + // ------------------------------------------------------------------------- + + /** Whether SMART data collection is enabled. Persisted in app.properties. */ + public static boolean enableSmart = false; + + // ------------------------------------------------------------------------- + // Fields + // ------------------------------------------------------------------------- + + @JsonProperty("json_format_version") + private List jsonFormatVersion; + + @JsonProperty("smartctl") + private SmartctlInfo smartctlInfo; + + @JsonProperty("device") + private DeviceInfo device; + + @JsonProperty("model_family") + private String modelFamily; + + @JsonProperty("model_name") + private String modelName; + + @JsonProperty("serial_number") + private String serialNumber; + + @JsonProperty("firmware_version") + private String firmwareVersion; + + @JsonProperty("user_capacity") + private UserCapacity userCapacity; + + @JsonProperty("smart_status") + private SmartStatus smartStatus; + + @JsonProperty("temperature") + private Temperature temperature; + + @JsonProperty("power_on_time") + private PowerOnTime powerOnTime; + + @JsonProperty("power_cycle_count") + private Integer powerCycleCount; + + @JsonProperty("ata_smart_attributes") + private AtaSmartAttributes ataSmartAttributes; + + @JsonProperty("nvme_smart_health_information_log") + private NvmeHealthLog nvmeHealthLog; + + // ------------------------------------------------------------------------- + // Factory / parsing + // ------------------------------------------------------------------------- + + /** No-arg constructor required by Jackson. */ + public Smart() {} + + /** + * Parses a {@code smartctl --json -a} JSON string into a {@link Smart} object. + * + * @param json the raw JSON string from smartctl + * @return a populated {@link Smart} instance + * @throws IOException if the JSON cannot be parsed + */ + public static Smart fromJson(String json) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(json, Smart.class); + } + + // ------------------------------------------------------------------------- + // Top-level getters & setters + // ------------------------------------------------------------------------- + + public List getJsonFormatVersion() { return jsonFormatVersion; } + public void setJsonFormatVersion(List jsonFormatVersion) { this.jsonFormatVersion = jsonFormatVersion; } + + public SmartctlInfo getSmartctlInfo() { return smartctlInfo; } + public void setSmartctlInfo(SmartctlInfo smartctlInfo) { this.smartctlInfo = smartctlInfo; } + + public DeviceInfo getDevice() { return device; } + public void setDevice(DeviceInfo device) { this.device = device; } + + public String getModelFamily() { return modelFamily; } + public void setModelFamily(String modelFamily) { this.modelFamily = modelFamily; } + + public String getModelName() { return modelName; } + public void setModelName(String modelName) { this.modelName = modelName; } + + public String getSerialNumber() { return serialNumber; } + public void setSerialNumber(String serialNumber) { this.serialNumber = serialNumber; } + + public String getFirmwareVersion() { return firmwareVersion; } + public void setFirmwareVersion(String firmwareVersion) { this.firmwareVersion = firmwareVersion; } + + public UserCapacity getUserCapacity() { return userCapacity; } + public void setUserCapacity(UserCapacity userCapacity) { this.userCapacity = userCapacity; } + + public SmartStatus getSmartStatus() { return smartStatus; } + public void setSmartStatus(SmartStatus smartStatus) { this.smartStatus = smartStatus; } + + public Temperature getTemperature() { return temperature; } + public void setTemperature(Temperature temperature) { this.temperature = temperature; } + + public PowerOnTime getPowerOnTime() { return powerOnTime; } + public void setPowerOnTime(PowerOnTime powerOnTime) { this.powerOnTime = powerOnTime; } + + public Integer getPowerCycleCount() { return powerCycleCount; } + public void setPowerCycleCount(Integer powerCycleCount) { this.powerCycleCount = powerCycleCount; } + + public AtaSmartAttributes getAtaSmartAttributes() { return ataSmartAttributes; } + public void setAtaSmartAttributes(AtaSmartAttributes ataSmartAttributes) { this.ataSmartAttributes = ataSmartAttributes; } + + public NvmeHealthLog getNvmeHealthLog() { return nvmeHealthLog; } + public void setNvmeHealthLog(NvmeHealthLog nvmeHealthLog) { this.nvmeHealthLog = nvmeHealthLog; } + + // ========================================================================= + // Nested classes + // ========================================================================= + + // ------------------------------------------------------------------------- + // smartctl tool info + // ------------------------------------------------------------------------- + + /** Represents the {@code smartctl} block containing tool version information. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SmartctlInfo { + + @JsonProperty("version") + private List version; + + @JsonProperty("svn_revision") + private String svnRevision; + + @JsonProperty("platform_info") + private String platformInfo; + + @JsonProperty("build_info") + private String buildInfo; + + @JsonProperty("exit_status") + private Integer exitStatus; + + @JsonProperty("messages") + private List messages; + + public SmartctlInfo() {} + + public List getVersion() { return version; } + public void setVersion(List version) { this.version = version; } + + public String getSvnRevision() { return svnRevision; } + public void setSvnRevision(String svnRevision) { this.svnRevision = svnRevision; } + + public String getPlatformInfo() { return platformInfo; } + public void setPlatformInfo(String platformInfo) { this.platformInfo = platformInfo; } + + public String getBuildInfo() { return buildInfo; } + public void setBuildInfo(String buildInfo) { this.buildInfo = buildInfo; } + + public Integer getExitStatus() { return exitStatus; } + public void setExitStatus(Integer exitStatus) { this.exitStatus = exitStatus; } + + public List getMessages() { return messages; } + public void setMessages(List messages) { this.messages = messages; } + + /** Returns a version string such as {@code "7.2"}. */ + public String getVersionString() { + if (version == null || version.isEmpty()) return "unknown"; + if (version.size() >= 2) return version.get(0) + "." + version.get(1); + return String.valueOf(version.get(0)); + } + } + + // ------------------------------------------------------------------------- + // smartctl messages + // ------------------------------------------------------------------------- + + /** A single message entry inside the {@code smartctl.messages} array. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SmartMessage { + + @JsonProperty("string") + private String string; + + @JsonProperty("severity") + private String severity; + + public SmartMessage() {} + + public String getString() { return string; } + public void setString(String string) { this.string = string; } + + public String getSeverity() { return severity; } + public void setSeverity(String severity) { this.severity = severity; } + } + + // ------------------------------------------------------------------------- + // device + // ------------------------------------------------------------------------- + + /** Represents the {@code device} block (name, info_name, type, protocol). */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DeviceInfo { + + @JsonProperty("name") + private String name; + + @JsonProperty("info_name") + private String infoName; + + @JsonProperty("type") + private String type; + + @JsonProperty("protocol") + private String protocol; + + public DeviceInfo() {} + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getInfoName() { return infoName; } + public void setInfoName(String infoName) { this.infoName = infoName; } + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public String getProtocol() { return protocol; } + public void setProtocol(String protocol) { this.protocol = protocol; } + } + + // ------------------------------------------------------------------------- + // user_capacity + // ------------------------------------------------------------------------- + + /** Represents the {@code user_capacity} block (bytes and blocks). */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class UserCapacity { + + @JsonProperty("blocks") + private Long blocks; + + @JsonProperty("bytes") + private Long bytes; + + public UserCapacity() {} + + public Long getBlocks() { return blocks; } + public void setBlocks(Long blocks) { this.blocks = blocks; } + + public Long getBytes() { return bytes; } + public void setBytes(Long bytes) { this.bytes = bytes; } + + /** Returns capacity in GB (1 GB = 10^9 bytes), rounded to 2 decimal places. */ + public double getCapacityGb() { + if (bytes == null) return 0; + return Math.round((bytes / 1_000_000_000.0) * 100.0) / 100.0; + } + } + + // ------------------------------------------------------------------------- + // smart_status + // ------------------------------------------------------------------------- + + /** Represents the {@code smart_status} block. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SmartStatus { + + @JsonProperty("passed") + private Boolean passed; + + public SmartStatus() {} + + public Boolean isPassed() { return passed; } + public void setPassed(Boolean passed) { this.passed = passed; } + } + + // ------------------------------------------------------------------------- + // temperature + // ------------------------------------------------------------------------- + + /** Represents the {@code temperature} block. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Temperature { + + @JsonProperty("current") + private Integer current; + + @JsonProperty("drive_trip") + private Integer driveTrip; + + public Temperature() {} + + public Integer getCurrent() { return current; } + public void setCurrent(Integer current) { this.current = current; } + + public Integer getDriveTrip() { return driveTrip; } + public void setDriveTrip(Integer driveTrip) { this.driveTrip = driveTrip; } + } + + // ------------------------------------------------------------------------- + // power_on_time + // ------------------------------------------------------------------------- + + /** Represents the {@code power_on_time} block. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class PowerOnTime { + + @JsonProperty("hours") + private Long hours; + + @JsonProperty("minutes") + private Integer minutes; + + public PowerOnTime() {} + + public Long getHours() { return hours; } + public void setHours(Long hours) { this.hours = hours; } + + public Integer getMinutes() { return minutes; } + public void setMinutes(Integer minutes) { this.minutes = minutes; } + } + + // ========================================================================= + // ATA SMART attributes (SATA / SAS drives) + // ========================================================================= + + /** Container for the {@code ata_smart_attributes} block. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AtaSmartAttributes { + + @JsonProperty("revision") + private Integer revision; + + @JsonProperty("table") + private List table; + + public AtaSmartAttributes() {} + + public Integer getRevision() { return revision; } + public void setRevision(Integer revision) { this.revision = revision; } + + public List getTable() { return table; } + public void setTable(List table) { this.table = table; } + } + + // ------------------------------------------------------------------------- + // Individual ATA attribute + // ------------------------------------------------------------------------- + + /** One row in the ATA SMART attributes table. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AtaAttribute { + + @JsonProperty("id") + private Integer id; + + @JsonProperty("name") + private String name; + + @JsonProperty("value") + private Integer value; + + @JsonProperty("worst") + private Integer worst; + + @JsonProperty("thresh") + private Integer thresh; + + @JsonProperty("when_failed") + private String whenFailed; + + @JsonProperty("flags") + private AtaAttributeFlags flags; + + @JsonProperty("raw") + private AtaAttributeRaw raw; + + public AtaAttribute() {} + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getValue() { return value; } + public void setValue(Integer value) { this.value = value; } + + public Integer getWorst() { return worst; } + public void setWorst(Integer worst) { this.worst = worst; } + + public Integer getThresh() { return thresh; } + public void setThresh(Integer thresh) { this.thresh = thresh; } + + public String getWhenFailed() { return whenFailed; } + public void setWhenFailed(String whenFailed) { this.whenFailed = whenFailed; } + + public AtaAttributeFlags getFlags() { return flags; } + public void setFlags(AtaAttributeFlags flags) { this.flags = flags; } + + public AtaAttributeRaw getRaw() { return raw; } + public void setRaw(AtaAttributeRaw raw) { this.raw = raw; } + + /** Returns {@code true} if the attribute's value is below its threshold. */ + public boolean isFailing() { + return value != null && thresh != null && value < thresh; + } + } + + // ------------------------------------------------------------------------- + // ATA attribute flags + // ------------------------------------------------------------------------- + + /** The {@code flags} subobject of an ATA attribute. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AtaAttributeFlags { + + @JsonProperty("value") + private Integer value; + + @JsonProperty("string") + private String string; + + @JsonProperty("prefailure") + private Boolean prefailure; + + @JsonProperty("updated_online") + private Boolean updatedOnline; + + @JsonProperty("performance") + private Boolean performance; + + @JsonProperty("error_rate") + private Boolean errorRate; + + @JsonProperty("event_count") + private Boolean eventCount; + + @JsonProperty("auto_keep") + private Boolean autoKeep; + + public AtaAttributeFlags() {} + + public Integer getValue() { return value; } + public void setValue(Integer value) { this.value = value; } + + public String getString() { return string; } + public void setString(String string) { this.string = string; } + + public Boolean isPrefailure() { return prefailure; } + public void setPrefailure(Boolean prefailure) { this.prefailure = prefailure; } + + public Boolean isUpdatedOnline() { return updatedOnline; } + public void setUpdatedOnline(Boolean updatedOnline) { this.updatedOnline = updatedOnline; } + + public Boolean isPerformance() { return performance; } + public void setPerformance(Boolean performance) { this.performance = performance; } + + public Boolean isErrorRate() { return errorRate; } + public void setErrorRate(Boolean errorRate) { this.errorRate = errorRate; } + + public Boolean isEventCount() { return eventCount; } + public void setEventCount(Boolean eventCount) { this.eventCount = eventCount; } + + public Boolean isAutoKeep() { return autoKeep; } + public void setAutoKeep(Boolean autoKeep) { this.autoKeep = autoKeep; } + } + + // ------------------------------------------------------------------------- + // ATA attribute raw value + // ------------------------------------------------------------------------- + + /** The {@code raw} subobject of an ATA attribute. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AtaAttributeRaw { + + @JsonProperty("value") + private Long value; + + @JsonProperty("string") + private String string; + + public AtaAttributeRaw() {} + + public Long getValue() { return value; } + public void setValue(Long value) { this.value = value; } + + public String getString() { return string; } + public void setString(String string) { this.string = string; } + } + + // ========================================================================= + // NVMe health log (NVMe drives) + // ========================================================================= + + /** + * Represents the {@code nvme_smart_health_information_log} block returned + * for NVMe devices. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class NvmeHealthLog { + + @JsonProperty("critical_warning") + private Integer criticalWarning; + + @JsonProperty("temperature") + private Integer temperature; + + @JsonProperty("available_spare") + private Integer availableSpare; + + @JsonProperty("available_spare_threshold") + private Integer availableSpareThreshold; + + @JsonProperty("percentage_used") + private Integer percentageUsed; + + @JsonProperty("data_units_read") + private Long dataUnitsRead; + + @JsonProperty("data_units_written") + private Long dataUnitsWritten; + + @JsonProperty("host_reads") + private Long hostReads; + + @JsonProperty("host_writes") + private Long hostWrites; + + @JsonProperty("controller_busy_time") + private Long controllerBusyTime; + + @JsonProperty("power_cycles") + private Long powerCycles; + + @JsonProperty("power_on_hours") + private Long powerOnHours; + + @JsonProperty("unsafe_shutdowns") + private Long unsafeShutdowns; + + @JsonProperty("media_errors") + private Long mediaErrors; + + @JsonProperty("num_err_log_entries") + private Long numErrLogEntries; + + @JsonProperty("warning_temp_time") + private Long warningTempTime; + + @JsonProperty("critical_comp_time") + private Long criticalCompTime; + + public NvmeHealthLog() {} + + public Integer getCriticalWarning() { return criticalWarning; } + public void setCriticalWarning(Integer criticalWarning) { this.criticalWarning = criticalWarning; } + + public Integer getTemperature() { return temperature; } + public void setTemperature(Integer temperature) { this.temperature = temperature; } + + public Integer getAvailableSpare() { return availableSpare; } + public void setAvailableSpare(Integer availableSpare) { this.availableSpare = availableSpare; } + + public Integer getAvailableSpareThreshold() { return availableSpareThreshold; } + public void setAvailableSpareThreshold(Integer availableSpareThreshold) { this.availableSpareThreshold = availableSpareThreshold; } + + public Integer getPercentageUsed() { return percentageUsed; } + public void setPercentageUsed(Integer percentageUsed) { this.percentageUsed = percentageUsed; } + + public Long getDataUnitsRead() { return dataUnitsRead; } + public void setDataUnitsRead(Long dataUnitsRead) { this.dataUnitsRead = dataUnitsRead; } + + public Long getDataUnitsWritten() { return dataUnitsWritten; } + public void setDataUnitsWritten(Long dataUnitsWritten) { this.dataUnitsWritten = dataUnitsWritten; } + + public Long getHostReads() { return hostReads; } + public void setHostReads(Long hostReads) { this.hostReads = hostReads; } + + public Long getHostWrites() { return hostWrites; } + public void setHostWrites(Long hostWrites) { this.hostWrites = hostWrites; } + + public Long getControllerBusyTime() { return controllerBusyTime; } + public void setControllerBusyTime(Long controllerBusyTime) { this.controllerBusyTime = controllerBusyTime; } + + public Long getPowerCycles() { return powerCycles; } + public void setPowerCycles(Long powerCycles) { this.powerCycles = powerCycles; } + + public Long getPowerOnHours() { return powerOnHours; } + public void setPowerOnHours(Long powerOnHours) { this.powerOnHours = powerOnHours; } + + public Long getUnsafeShutdowns() { return unsafeShutdowns; } + public void setUnsafeShutdowns(Long unsafeShutdowns) { this.unsafeShutdowns = unsafeShutdowns; } + + public Long getMediaErrors() { return mediaErrors; } + public void setMediaErrors(Long mediaErrors) { this.mediaErrors = mediaErrors; } + + public Long getNumErrLogEntries() { return numErrLogEntries; } + public void setNumErrLogEntries(Long numErrLogEntries) { this.numErrLogEntries = numErrLogEntries; } + + public Long getWarningTempTime() { return warningTempTime; } + public void setWarningTempTime(Long warningTempTime) { this.warningTempTime = warningTempTime; } + + public Long getCriticalCompTime() { return criticalCompTime; } + public void setCriticalCompTime(Long criticalCompTime) { this.criticalCompTime = criticalCompTime; } + + /** + * Returns {@code true} if {@code critical_warning} is non-zero, + * indicating a health issue that needs attention. + */ + public boolean hasCriticalWarning() { + return criticalWarning != null && criticalWarning != 0; + } + + /** + * Converts NVMe data units written (1 unit = 512,000 bytes) to GB. + * Returns 0 if the field is null. + */ + public double getDataWrittenGb() { + if (dataUnitsWritten == null) return 0; + return Math.round((dataUnitsWritten * 512_000.0 / 1_000_000_000.0) * 100.0) / 100.0; + } + + /** + * Converts NVMe data units read (1 unit = 512,000 bytes) to GB. + * Returns 0 if the field is null. + */ + public double getDataReadGb() { + if (dataUnitsRead == null) return 0; + return Math.round((dataUnitsRead * 512_000.0 / 1_000_000_000.0) * 100.0) / 100.0; + } + } +} diff --git a/src/jdiskmark/SmartPanel.java b/src/jdiskmark/SmartPanel.java new file mode 100644 index 0000000..f716979 --- /dev/null +++ b/src/jdiskmark/SmartPanel.java @@ -0,0 +1,360 @@ +package jdiskmark; + +import java.awt.Color; +import java.awt.Font; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.SwingUtilities; +import javax.swing.table.DefaultTableModel; +import net.miginfocom.swing.MigLayout; + +/** + * Displays parsed S.M.A.R.T. data in the main "SMART" tab. + * + * Call {@link #populate(Smart)} on the Event Dispatch Thread (or from + * any thread — it marshals to the EDT internally) after the benchmark runner + * has retrieved and parsed the {@code smartctl} JSON output. + * + *

Layout sections: + *

    + *
  • Drive Info – model, serial, firmware, capacity, protocol
  • + *
  • Health – SMART status, temperature, power-on hours, power cycles
  • + *
  • NVMe Health Log – available spare, % used, data written/read, errors (NVMe only)
  • + *
  • ATA Attributes – scrollable table of all ATA SMART attributes (SATA only)
  • + *
+ * + * @author jasmine + */ +public class SmartPanel extends javax.swing.JPanel { + + // ------------------------------------------------------------------------- + // Drive Info labels + // ------------------------------------------------------------------------- + private final JLabel modelValueLabel = value("-"); + private final JLabel serialValueLabel = value("-"); + private final JLabel firmwareValueLabel = value("-"); + private final JLabel capacityValueLabel = value("-"); + private final JLabel protocolValueLabel = value("-"); + + // ------------------------------------------------------------------------- + // Health labels + // ------------------------------------------------------------------------- + private final JLabel statusValueLabel = value("-"); + private final JLabel tempValueLabel = value("-"); + private final JLabel powerOnValueLabel = value("-"); + private final JLabel powerCyclesValueLabel = value("-"); + + // ------------------------------------------------------------------------- + // NVMe-specific labels + // ------------------------------------------------------------------------- + private final JLabel spareValueLabel = value("-"); + private final JLabel usedPctValueLabel = value("-"); + private final JLabel writtenValueLabel = value("-"); + private final JLabel readValueLabel = value("-"); + private final JLabel mediaErrValueLabel = value("-"); + private final JLabel errLogValueLabel = value("-"); + private final JLabel warnTempValueLabel = value("-"); + private final JLabel critCompValueLabel = value("-"); + private JPanel nvmeSection; + + // ------------------------------------------------------------------------- + // ATA Attributes table + // ------------------------------------------------------------------------- + private final DefaultTableModel ataModel; + private final JTable ataTable; + private JPanel ataSection; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + public SmartPanel() { + // ATA table model + ataModel = new DefaultTableModel( + new String[]{"ID", "Attribute Name", "Value", "Worst", "Threshold", "Raw", "Status"}, + 0 + ) { + @Override + public boolean isCellEditable(int row, int col) { return false; } + }; + ataTable = new JTable(ataModel); + ataTable.setFillsViewportHeight(true); + ataTable.getColumnModel().getColumn(0).setPreferredWidth(30); + ataTable.getColumnModel().getColumn(1).setPreferredWidth(200); + ataTable.getColumnModel().getColumn(2).setPreferredWidth(45); + ataTable.getColumnModel().getColumn(3).setPreferredWidth(45); + ataTable.getColumnModel().getColumn(4).setPreferredWidth(60); + ataTable.getColumnModel().getColumn(5).setPreferredWidth(80); + ataTable.getColumnModel().getColumn(6).setPreferredWidth(60); + + buildLayout(); + } + + // ------------------------------------------------------------------------- + // Layout + // ------------------------------------------------------------------------- + + private void buildLayout() { + setLayout(new MigLayout("insets 12, fillx", "[grow]", "[]8[]8[]8[]")); + + // --- Drive Info section --- + JPanel driveSection = section("Drive Info"); + driveSection.setLayout(new MigLayout("insets 8, wrap 4", "[120][grow][120][grow]")); + driveSection.add(label("Model:")); + driveSection.add(modelValueLabel, "growx"); + driveSection.add(label("Protocol:")); + driveSection.add(protocolValueLabel, "growx, wrap"); + driveSection.add(label("Serial:")); + driveSection.add(serialValueLabel, "growx"); + driveSection.add(label("Firmware:")); + driveSection.add(firmwareValueLabel, "growx, wrap"); + driveSection.add(label("Capacity:")); + driveSection.add(capacityValueLabel, "growx, span 3"); + add(driveSection, "growx, wrap"); + + // --- Health section --- + JPanel healthSection = section("Health"); + healthSection.setLayout(new MigLayout("insets 8, wrap 4", "[120][grow][120][grow]")); + healthSection.add(label("SMART Status:")); + healthSection.add(statusValueLabel, "growx"); + healthSection.add(label("Temperature:")); + healthSection.add(tempValueLabel, "growx, wrap"); + healthSection.add(label("Power-On Hours:")); + healthSection.add(powerOnValueLabel, "growx"); + healthSection.add(label("Power Cycles:")); + healthSection.add(powerCyclesValueLabel, "growx"); + add(healthSection, "growx, wrap"); + + // --- NVMe Health Log section (hidden until populated) --- + nvmeSection = section("NVMe Health Log"); + nvmeSection.setLayout(new MigLayout("insets 8, wrap 4", "[150][grow][150][grow]")); + nvmeSection.add(label("Available Spare:")); + nvmeSection.add(spareValueLabel, "growx"); + nvmeSection.add(label("% Used (PE cycles):")); + nvmeSection.add(usedPctValueLabel, "growx, wrap"); + nvmeSection.add(label("Data Written:")); + nvmeSection.add(writtenValueLabel, "growx"); + nvmeSection.add(label("Data Read:")); + nvmeSection.add(readValueLabel, "growx, wrap"); + nvmeSection.add(label("Media Errors:")); + nvmeSection.add(mediaErrValueLabel, "growx"); + nvmeSection.add(label("Error Log Entries:")); + nvmeSection.add(errLogValueLabel, "growx, wrap"); + nvmeSection.add(label("Warning Temp Time:")); + nvmeSection.add(warnTempValueLabel, "growx"); + nvmeSection.add(label("Critical Comp Time:")); + nvmeSection.add(critCompValueLabel, "growx"); + nvmeSection.setVisible(false); + add(nvmeSection, "growx, wrap"); + + // --- ATA Attributes section (hidden until populated) --- + ataSection = section("ATA SMART Attributes"); + ataSection.setLayout(new MigLayout("insets 8, fill", "[grow]", "[grow]")); + JScrollPane scrollPane = new JScrollPane(ataTable); + scrollPane.setPreferredSize(new java.awt.Dimension(600, 200)); + ataSection.add(scrollPane, "grow"); + ataSection.setVisible(false); + add(ataSection, "growx, wrap"); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Populates all fields from the given {@link Smart}. + * Safe to call from any thread — marshals to the EDT automatically. + * + * @param data the parsed SMART data; if {@code null} the panel is cleared + */ + public void populate(Smart data) { + SwingUtilities.invokeLater(() -> { + if (data == null) { + clear(); + return; + } + fillDriveInfo(data); + fillHealth(data); + fillNvme(data.getNvmeHealthLog()); + fillAtaAttributes(data.getAtaSmartAttributes()); + revalidate(); + repaint(); + }); + } + + /** Resets all fields to their default placeholder values. */ + public void clear() { + SwingUtilities.invokeLater(() -> { + for (JLabel l : new JLabel[]{ + modelValueLabel, serialValueLabel, firmwareValueLabel, + capacityValueLabel, protocolValueLabel, + statusValueLabel, tempValueLabel, + powerOnValueLabel, powerCyclesValueLabel, + spareValueLabel, usedPctValueLabel, writtenValueLabel, + readValueLabel, mediaErrValueLabel, errLogValueLabel, + warnTempValueLabel, critCompValueLabel + }) { + l.setText("-"); + l.setForeground(null); + } + ataModel.setRowCount(0); + nvmeSection.setVisible(false); + ataSection.setVisible(false); + revalidate(); + repaint(); + }); + } + + // ------------------------------------------------------------------------- + // Private fill helpers + // ------------------------------------------------------------------------- + + private void fillDriveInfo(Smart d) { + modelValueLabel.setText(orDash(d.getModelName())); + serialValueLabel.setText(orDash(d.getSerialNumber())); + firmwareValueLabel.setText(orDash(d.getFirmwareVersion())); + + if (d.getUserCapacity() != null) { + capacityValueLabel.setText(d.getUserCapacity().getCapacityGb() + " GB"); + } + if (d.getDevice() != null) { + protocolValueLabel.setText(orDash(d.getDevice().getProtocol())); + } + } + + private void fillHealth(Smart d) { + // SMART status + if (d.getSmartStatus() != null) { + boolean passed = Boolean.TRUE.equals(d.getSmartStatus().isPassed()); + statusValueLabel.setText(passed ? "PASSED ✔" : "FAILED ✘"); + statusValueLabel.setForeground(passed ? new Color(0x4CAF50) : new Color(0xF44336)); + } + + // Temperature + if (d.getTemperature() != null && d.getTemperature().getCurrent() != null) { + int temp = d.getTemperature().getCurrent(); + tempValueLabel.setText(temp + " °C"); + // colour-code: green < 45, amber < 60, red ≥ 60 + if (temp >= 60) { + tempValueLabel.setForeground(new Color(0xF44336)); + } else if (temp >= 45) { + tempValueLabel.setForeground(new Color(0xFF9800)); + } else { + tempValueLabel.setForeground(new Color(0x4CAF50)); + } + } + + // Power-on hours + if (d.getPowerOnTime() != null && d.getPowerOnTime().getHours() != null) { + powerOnValueLabel.setText(d.getPowerOnTime().getHours() + " h"); + } + + // Power cycles + if (d.getPowerCycleCount() != null) { + powerCyclesValueLabel.setText(String.valueOf(d.getPowerCycleCount())); + } + } + + private void fillNvme(Smart.NvmeHealthLog nvme) { + if (nvme == null) { + nvmeSection.setVisible(false); + return; + } + nvmeSection.setVisible(true); + + setNvmeField(spareValueLabel, nvme.getAvailableSpare(), "%", + nvme.getAvailableSpareThreshold(), true); + setNvmeField(usedPctValueLabel, nvme.getPercentageUsed(), "%", null, false); + writtenValueLabel.setText(nvme.getDataWrittenGb() + " GB (" + nvme.getDataUnitsWritten() + " units)"); + readValueLabel.setText(nvme.getDataReadGb() + " GB (" + nvme.getDataUnitsRead() + " units)"); + + // Error counts — colour red on non-zero + setCountField(mediaErrValueLabel, nvme.getMediaErrors()); + setCountField(errLogValueLabel, nvme.getNumErrLogEntries()); + + warnTempValueLabel.setText(nvme.getWarningTempTime() != null + ? nvme.getWarningTempTime() + " min" : "-"); + critCompValueLabel.setText(nvme.getCriticalCompTime() != null + ? nvme.getCriticalCompTime() + " min" : "-"); + + // Flag critical warning + if (nvme.hasCriticalWarning()) { + statusValueLabel.setText("CRITICAL WARNING (" + nvme.getCriticalWarning() + ") ✘"); + statusValueLabel.setForeground(new Color(0xF44336)); + } + } + + private void fillAtaAttributes(Smart.AtaSmartAttributes ata) { + ataModel.setRowCount(0); + if (ata == null || ata.getTable() == null || ata.getTable().isEmpty()) { + ataSection.setVisible(false); + return; + } + ataSection.setVisible(true); + List table = ata.getTable(); + for (Smart.AtaAttribute attr : table) { + String raw = attr.getRaw() != null ? attr.getRaw().getString() : "-"; + String status = attr.isFailing() ? "FAILING ✘" : "OK"; + ataModel.addRow(new Object[]{ + attr.getId(), + orDash(attr.getName()), + attr.getValue(), + attr.getWorst(), + attr.getThresh(), + raw, + status + }); + } + } + + // ------------------------------------------------------------------------- + // Small helpers + // ------------------------------------------------------------------------- + + /** Labels a field value with optional threshold colouring. */ + private void setNvmeField(JLabel lbl, Integer value, String suffix, + Integer threshold, boolean higherIsBetter) { + if (value == null) { lbl.setText("-"); return; } + lbl.setText(value + suffix); + if (threshold != null) { + boolean warn = higherIsBetter ? value <= threshold : value >= threshold; + lbl.setForeground(warn ? new Color(0xF44336) : new Color(0x4CAF50)); + } + } + + /** Sets a count field to red on non-zero. */ + private void setCountField(JLabel lbl, Long value) { + if (value == null) { lbl.setText("-"); return; } + lbl.setText(String.valueOf(value)); + lbl.setForeground(value > 0 ? new Color(0xF44336) : null); + } + + /** Returns {@code s} if non-null/non-empty, else {@code "-"}. */ + private static String orDash(String s) { + return (s != null && !s.isBlank()) ? s : "-"; + } + + /** Creates a right-aligned bold key label. */ + private static JLabel label(String text) { + JLabel l = new JLabel(text); + l.setFont(l.getFont().deriveFont(Font.BOLD)); + return l; + } + + /** Creates a left-aligned plain value label. */ + private static JLabel value(String text) { + return new JLabel(text); + } + + /** Creates a titled, etched-border section panel. */ + private static JPanel section(String title) { + JPanel p = new JPanel(); + p.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createEtchedBorder(), title)); + return p; + } +} From e72d366c6d7b99be6edad5b2bae8eb038b0fc1d1 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sun, 17 May 2026 21:32:30 -0700 Subject: [PATCH 02/22] #61 refactor: rename Smart enable property to smartEnable for naming consistency and update menu in load --- jdm-core/src/main/java/jdiskmark/App.java | 10 +++++----- jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java | 2 +- jdm-core/src/main/java/jdiskmark/MainFrame.java | 3 ++- jdm-core/src/main/java/jdiskmark/Smart.java | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/App.java b/jdm-core/src/main/java/jdiskmark/App.java index 5a512f2..bb7c141 100644 --- a/jdm-core/src/main/java/jdiskmark/App.java +++ b/jdm-core/src/main/java/jdiskmark/App.java @@ -324,7 +324,7 @@ public static void loadProfile(BenchmarkProfile profile) { writeSyncEnable = profile.isWriteSyncEnable(); sectorAlignment = profile.getSectorAlignment(); multiFile = profile.isMultiFile(); -// Smart.enableSmart = profile.getEnableSmart(); +// Smart.smartEnable = profile.getEnableSmart(); } finally { saveConfig(); } @@ -382,8 +382,8 @@ public static void loadConfig() { value = p.getProperty("multiFile", String.valueOf(multiFile)); multiFile = Boolean.parseBoolean(value); - value = p.getProperty("enableSmart", String.valueOf(Smart.enableSmart)); - Smart.enableSmart = Boolean.parseBoolean(value); + value = p.getProperty("smartEnable", String.valueOf(Smart.smartEnable)); + Smart.smartEnable = Boolean.parseBoolean(value); value = p.getProperty("autoRemoveData", String.valueOf(autoRemoveData)); autoRemoveData = Boolean.parseBoolean(value); @@ -475,7 +475,7 @@ public static void saveConfig() { p.setProperty("profileModified", String.valueOf(profileModified)); p.setProperty("benchmarkType", benchmarkType.name()); p.setProperty("multiFile", String.valueOf(multiFile)); - p.setProperty("enableSmart", String.valueOf(Smart.enableSmart)); + p.setProperty("smartEnable", String.valueOf(Smart.smartEnable)); p.setProperty("autoRemoveData", String.valueOf(autoRemoveData)); p.setProperty("autoReset", String.valueOf(autoReset)); p.setProperty("blockSequence", blockSequence.name()); @@ -528,7 +528,7 @@ public static BenchmarkConfig getConfig() { config.gcRetryEnabled = GcDetector.gcRetryEnabled; config.gcHintsEnabled = GcDetector.gcHintsEnabled; config.multiFileEnabled = multiFile; -// config.enabledSmart = Smart.enableSmart; --- TODO in config --- +// config.enabledSmart = Smart.smartEnable; --- TODO in config --- config.testDir = dataDir.getAbsolutePath(); return config; } diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index c81810b..ea802df 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -121,7 +121,7 @@ public Benchmark execute() throws Exception { GcDetector.triggerAndWait(); // Initial cleanup } - if (Smart.enableSmart && App.isLinux()) { + if (Smart.smartEnable && App.isLinux()) { // privilege escallation String testDir = config.getTestDir(); String partition = UtilOs.getPartitionFromFilePathLinux(Path.of(testDir)); diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index c2db435..30f8d97 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -201,6 +201,7 @@ public void refreshConfig() { } gcHintsCbMenuItem.setSelected(GcDetector.gcHintsEnabled); gcRetryCbMenuItem.setSelected(GcDetector.gcRetryEnabled); + smartCbMenuItem.setSelected(Smart.smartEnable); exportMenu.setEnabled(App.benchmark != null); } @@ -1129,7 +1130,7 @@ private void httpsProtoRbMenuItemActionPerformed(java.awt.event.ActionEvent evt) }//GEN-LAST:event_httpsProtoRbMenuItemActionPerformed private void smartCbMenuItemActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_smartCbMenuItemActionPerformed - Smart.enableSmart = this.smartCbMenuItem.isSelected(); + Smart.smartEnable = this.smartCbMenuItem.isSelected(); App.saveConfig(); }//GEN-LAST:event_smartCbMenuItemActionPerformed diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index 2161ce4..a296b98 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -44,7 +44,7 @@ public class Smart { // ------------------------------------------------------------------------- /** Whether SMART data collection is enabled. Persisted in app.properties. */ - public static boolean enableSmart = false; + public static boolean smartEnable = false; // ------------------------------------------------------------------------- // Fields From a239992e346ef10ebb4b13655d0bfc9ec71c6d69 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Mon, 18 May 2026 00:23:27 -0700 Subject: [PATCH 03/22] #61 Heartbeat thread persist escalated shell --- .../main/java/jdiskmark/BenchmarkRunner.java | 42 ++--- jdm-core/src/main/java/jdiskmark/Smart.java | 149 ++++++++++++++++++ 2 files changed, 159 insertions(+), 32 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index ea802df..d25b88d 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -1,6 +1,5 @@ package jdiskmark; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; import static jdiskmark.GcDetector.MAX_GC_RETRIES; @@ -122,42 +121,21 @@ public Benchmark execute() throws Exception { } if (Smart.smartEnable && App.isLinux()) { -// privilege escallation String testDir = config.getTestDir(); String partition = UtilOs.getPartitionFromFilePathLinux(Path.of(testDir)); String deviceName = UtilOs.getDeviceNamesFromPartitionLinux(partition).get(0); - -// System.out.println(deviceName); - ProcessBuilder pb = new ProcessBuilder("pkexec", "/usr/sbin/smartctl", "--json", "-a", "/dev/" + deviceName); - Process process = pb.start(); - - String result = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - Smart smart = Smart.fromJson(result); - logger.log(Level.INFO, "SMART model : {0}", smart.getModelName()); - logger.log(Level.INFO, "SMART serial : {0}", smart.getSerialNumber()); - logger.log(Level.INFO, "SMART firmware : {0}", smart.getFirmwareVersion()); - if (smart.getSmartStatus() != null) { - logger.log(Level.INFO, "SMART status : {0}", smart.getSmartStatus().isPassed() ? "PASSED" : "FAILED"); - } - if (smart.getTemperature() != null) { - logger.log(Level.INFO, "SMART temp : {0} C", smart.getTemperature().getCurrent()); - } - if (smart.getPowerOnTime() != null) { - logger.log(Level.INFO, "SMART power-on : {0} hours", smart.getPowerOnTime().getHours()); - } - if (smart.getNvmeHealthLog() != null) { - Smart.NvmeHealthLog nvme = smart.getNvmeHealthLog(); - logger.log(Level.INFO, "NVMe avail spare : {0}%", nvme.getAvailableSpare()); - logger.log(Level.INFO, "NVMe used % : {0}%", nvme.getPercentageUsed()); - logger.log(Level.INFO, "NVMe written : {0} GB", nvme.getDataWrittenGb()); - logger.log(Level.INFO, "NVMe read : {0} GB", nvme.getDataReadGb()); - logger.log(Level.INFO, "NVMe media errs : {0}", nvme.getMediaErrors()); - if (nvme.hasCriticalWarning()) { - logger.log(Level.WARNING, "NVMe critical warning flag: {0}", nvme.getCriticalWarning()); - } + + // Ensure the privileged bash shell is alive (no-op if already running; + // pkexec prompts the user only on the very first benchmark run). + if (Smart.process == null || !Smart.process.isAlive()) { + Smart.startPrivilegedShell(); + Smart.startHeartbeat(); } + + Smart smart = Smart.getSmart(deviceName); + // Populate the SMART tab in the GUI - if (Gui.smartPanel != null) { + if (Gui.smartPanel != null && smart != null) { Gui.smartPanel.populate(smart); } } diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index a296b98..72bd8df 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -3,8 +3,16 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; /** * Parses the JSON output of {@code smartctl --json -a /dev/<device>}. @@ -45,7 +53,148 @@ public class Smart { /** Whether SMART data collection is enabled. Persisted in app.properties. */ public static boolean smartEnable = false; + + /** The long-lived privileged bash shell process, started once via pkexec. */ + public static Process process; + /** Writer attached to the bash shell's stdin for sending commands. */ + public static BufferedWriter shellWriter; + /** Reader attached to the bash shell's stdout for reading command output. */ + public static BufferedReader shellReader; + public static Thread hbThread; + public static final Object pLock = new Object(); + + private static final Logger LOGGER = Logger.getLogger(Smart.class.getName()); + /** + * Launches a single {@code pkexec bash} process and wires up the + * shared {@link #shellWriter} / {@link #shellReader}. The user is + * prompted for their password exactly once; subsequent SMART queries + * reuse this shell without re-escalating privileges. + * + *

Safe to call multiple times — a no-op if the shell is already alive. + * + * @throws IOException if the process cannot be started + */ + public static void startPrivilegedShell() throws IOException { + synchronized (pLock) { + if (process != null && process.isAlive()) { + return; // already running + } + LOGGER.info("Starting privileged bash shell via pkexec..."); + ProcessBuilder pb = new ProcessBuilder("pkexec", "bash"); + pb.redirectErrorStream(false); // keep stderr separate from stdout + process = pb.start(); + shellWriter = new BufferedWriter( + new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + shellReader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + LOGGER.info("Privileged shell started (pid reuse enabled)."); + } + } + + /** + * Starts a background keepalive thread that pings the privileged bash + * shell every 5 minutes with a no-op echo so the process stays alive. + * Only one thread is started; subsequent calls are ignored. + */ + public static void startHeartbeat() { + if (hbThread != null && hbThread.isAlive()) return; + hbThread = new Thread(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + TimeUnit.MINUTES.sleep(5); + synchronized (pLock) { + if (process != null && process.isAlive() && shellWriter != null) { + shellWriter.write("echo heartbeat\n"); + shellWriter.flush(); + // Drain the echo reply so it doesn't pollute the next getSmart() read + shellReader.readLine(); + } + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } catch (IOException ex) { + LOGGER.log(Level.WARNING, "Heartbeat write failed; shell may have died", ex); + } + } + }, "smart-heartbeat"); + hbThread.setDaemon(true); + hbThread.start(); + } + + /** + * Queries SMART data for the given device by writing a {@code smartctl} + * command to the persistent privileged shell and reading back its output + * up to a unique sentinel line. Logs the key fields at INFO level. + * + *

{@link #startPrivilegedShell()} must have been called before this. + * + * @param deviceName bare device name, e.g. {@code nvme0n1} + * @return a populated {@link Smart} instance, or {@code null} on error + */ + public static Smart getSmart(String deviceName) { + final String sentinel = "---SMART_DONE---"; + try { + synchronized (pLock) { + // Write the smartctl command followed by an echo of the sentinel + // so we know exactly where the JSON output ends. + shellWriter.write("/usr/sbin/smartctl --json -a /dev/" + deviceName + "\n"); + shellWriter.write("echo '" + sentinel + "'\n"); + shellWriter.flush(); + + // Accumulate lines until the sentinel appears + StringBuilder sb = new StringBuilder(); + String line; + while ((line = shellReader.readLine()) != null) { + if (sentinel.equals(line)) break; + sb.append(line).append('\n'); + } + + String result = sb.toString().trim(); + if (result.isEmpty()) { + LOGGER.severe("getSmart: empty response from shell for device " + deviceName); + return null; + } + + Smart smart = fromJson(result); + logSmart(smart); + return smart; + } + } catch (IOException ex) { + LOGGER.log(Level.SEVERE, "getSmart failed for device: " + deviceName, ex); + } + return null; + } + + /** Logs the key SMART fields at INFO level. */ + public static void logSmart(Smart smart) { + if (smart == null) return; + LOGGER.log(Level.INFO, "SMART model : {0}", smart.getModelName()); + LOGGER.log(Level.INFO, "SMART serial : {0}", smart.getSerialNumber()); + LOGGER.log(Level.INFO, "SMART firmware : {0}", smart.getFirmwareVersion()); + if (smart.getSmartStatus() != null) { + LOGGER.log(Level.INFO, "SMART status : {0}", + smart.getSmartStatus().isPassed() ? "PASSED" : "FAILED"); + } + if (smart.getTemperature() != null) { + LOGGER.log(Level.INFO, "SMART temp : {0} C", smart.getTemperature().getCurrent()); + } + if (smart.getPowerOnTime() != null) { + LOGGER.log(Level.INFO, "SMART power-on : {0} hours", smart.getPowerOnTime().getHours()); + } + if (smart.getNvmeHealthLog() != null) { + NvmeHealthLog nvme = smart.getNvmeHealthLog(); + LOGGER.log(Level.INFO, "NVMe avail spare : {0}%", nvme.getAvailableSpare()); + LOGGER.log(Level.INFO, "NVMe used % : {0}%", nvme.getPercentageUsed()); + LOGGER.log(Level.INFO, "NVMe written : {0} GB", nvme.getDataWrittenGb()); + LOGGER.log(Level.INFO, "NVMe read : {0} GB", nvme.getDataReadGb()); + LOGGER.log(Level.INFO, "NVMe media errs : {0}", nvme.getMediaErrors()); + if (nvme.hasCriticalWarning()) { + LOGGER.log(Level.WARNING, "NVMe critical warning flag: {0}", nvme.getCriticalWarning()); + } + } + } + // ------------------------------------------------------------------------- // Fields // ------------------------------------------------------------------------- From cbd284bcd8dfaaafb9c67513f62107df94508d10 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sun, 24 May 2026 22:34:54 -0700 Subject: [PATCH 04/22] #61 Shows the NVMe Health Log when no data is present --- jdm-core/src/main/java/jdiskmark/SmartPanel.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/SmartPanel.java b/jdm-core/src/main/java/jdiskmark/SmartPanel.java index f716979..a398f54 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SmartPanel.java @@ -148,7 +148,7 @@ private void buildLayout() { nvmeSection.add(warnTempValueLabel, "growx"); nvmeSection.add(label("Critical Comp Time:")); nvmeSection.add(critCompValueLabel, "growx"); - nvmeSection.setVisible(false); + nvmeSection.setVisible(true); // shown by default with dash placeholders; populated after auth add(nvmeSection, "growx, wrap"); // --- ATA Attributes section (hidden until populated) --- @@ -202,7 +202,7 @@ public void clear() { l.setForeground(null); } ataModel.setRowCount(0); - nvmeSection.setVisible(false); + nvmeSection.setVisible(true); // keep visible so placeholders remain shown ataSection.setVisible(false); revalidate(); repaint(); From 7f34dc32ee83231cf185ba28985c1fe0bc5b3476 Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 7 Jun 2026 19:39:34 -0700 Subject: [PATCH 05/22] #61 remove duplicate --- jdm-core/src/main/java/jdiskmark/App.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/App.java b/jdm-core/src/main/java/jdiskmark/App.java index 1dc5278..00dea64 100644 --- a/jdm-core/src/main/java/jdiskmark/App.java +++ b/jdm-core/src/main/java/jdiskmark/App.java @@ -1115,11 +1115,4 @@ static public void setLocationDir(File directory) { locationDir = directory; dataDir = new File(locationDir.getAbsolutePath() + File.separator + DATADIRNAME); } - - static public boolean isLinux() { - if (os == null) { - os = System.getProperty("os.name"); - } - return os.contains("Linux"); - } } From 0e87c50e1a9dea6a1ce5415b0a0dc19a9b9778c5 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sun, 7 Jun 2026 22:06:24 -0700 Subject: [PATCH 06/22] #61 Added Drives tab and functionality --- .../src/main/java/jdiskmark/DrivesPanel.java | 432 ++++++++++++++++++ jdm-core/src/main/java/jdiskmark/Gui.java | 5 + .../src/main/java/jdiskmark/MainFrame.java | 53 ++- 3 files changed, 468 insertions(+), 22 deletions(-) create mode 100644 jdm-core/src/main/java/jdiskmark/DrivesPanel.java diff --git a/jdm-core/src/main/java/jdiskmark/DrivesPanel.java b/jdm-core/src/main/java/jdiskmark/DrivesPanel.java new file mode 100644 index 0000000..c424681 --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/DrivesPanel.java @@ -0,0 +1,432 @@ +package jdiskmark; + +import java.awt.BorderLayout; +import java.awt.Desktop; +import java.awt.Font; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.io.File; +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTable; +import javax.swing.JTextField; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.SwingWorker; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.DefaultTableModel; + +/** + * Drives tab — lets the user select the benchmark target drive, shows drive + * capacity info, and manages the test-directory path. + * + *

+ * ┌──────────────────────────────────────────────────────┐
+ * │  Drive:  [combo box ──────────────────────────────]  │  ← NORTH
+ * ├─────────────────────────┬────────────────────────────┤
+ * │                         │                            │
+ * │   Drive Info            │   All Drives Table         │  ← CENTER (JSplitPane)
+ * │   (selected drive)      │                            │
+ * │                         │                            │
+ * ├─────────────────────────┴────────────────────────────┤
+ * │  Test Dir: [path ────────────────────] [Browse] [Open]│  ← SOUTH
+ * └──────────────────────────────────────────────────────┘
+ * 
+ */ +public class DrivesPanel extends JPanel { + + // ----------------------------------------------------------------------- + // Inner type — one item in the drive combo box + // ----------------------------------------------------------------------- + + private static class DriveEntry { + final File root; + final String label; + + DriveEntry(File root) { + this.root = root; + double totalGb = root.getTotalSpace() / (double) App.GIGABYTE; + String typeDesc = Util.getDriveType(root); + String type = (typeDesc != null && !typeDesc.isBlank()) + ? " [" + typeDesc + "]" : ""; + label = root.getAbsolutePath() + type + " — " + + String.format("%.0f GB", totalGb); + } + + @Override public String toString() { return label; } + } + + // ----------------------------------------------------------------------- + // UI fields + // ----------------------------------------------------------------------- + + private final JComboBox driveCombo; + + // Drive info labels (left pane) + private JLabel infoModelLabel; + private JLabel infoPartitionLabel; + private JLabel infoUsageLabel; + private JLabel accessLabel; + private JProgressBar usageBar; + + // Test directory row (bottom) + private final JTextField pathField; + private final JButton browseButton; + private final JButton openButton; + + // All-drives table (right pane) + private final DefaultTableModel tableModel; + private final JTable table; + + private static final String[] COLUMNS = { + "Drive / Mount", "Total (GB)", "Used (GB)", "Free (GB)", "Usage %" + }; + + private static final Logger LOG = Logger.getLogger(DrivesPanel.class.getName()); + + /** Prevents combo listener from firing during a programmatic refresh(). */ + private boolean suppressComboEvents = false; + + // ----------------------------------------------------------------------- + // Constructor + // ----------------------------------------------------------------------- + + public DrivesPanel() { + setLayout(new BorderLayout(0, 0)); + setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6)); + + // ── NORTH — Drive selector row ─────────────────────────────────────── + JPanel selectorRow = new JPanel(new BorderLayout(6, 0)); + selectorRow.setBorder(BorderFactory.createEmptyBorder(0, 0, 6, 0)); + + JLabel driveLabel = new JLabel("Drive:"); + driveLabel.setFont(driveLabel.getFont().deriveFont(Font.BOLD)); + driveLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 6)); + selectorRow.add(driveLabel, BorderLayout.WEST); + + driveCombo = new JComboBox<>(); + driveCombo.setMaximumRowCount(12); + populateCombo(); + selectorRow.add(driveCombo, BorderLayout.CENTER); + + add(selectorRow, BorderLayout.NORTH); + + // ── CENTER — JSplitPane: Drive Info (left) | All Drives Table (right) - + // Left pane: Drive Info + JPanel leftPane = buildDriveInfoPane(); + + // Right pane: All Drives table + tableModel = new DefaultTableModel(COLUMNS, 0) { + @Override public boolean isCellEditable(int r, int c) { return false; } + @Override public Class getColumnClass(int col) { + return switch (col) { + case 1, 2, 3, 4 -> Double.class; + default -> String.class; + }; + } + }; + + table = new JTable(tableModel); + table.setFillsViewportHeight(true); + table.setRowHeight(22); + table.setAutoCreateRowSorter(true); + table.getTableHeader().setFont( + table.getTableHeader().getFont().deriveFont(Font.BOLD)); + + DefaultTableCellRenderer rightR = new DefaultTableCellRenderer(); + rightR.setHorizontalAlignment(SwingConstants.RIGHT); + for (int i = 1; i <= 4; i++) { + table.getColumnModel().getColumn(i).setCellRenderer(rightR); + } + table.getColumnModel().getColumn(0).setPreferredWidth(180); + for (int i = 1; i <= 3; i++) table.getColumnModel().getColumn(i).setPreferredWidth(75); + table.getColumnModel().getColumn(4).setPreferredWidth(60); + + JPanel rightPane = new JPanel(new BorderLayout()); + rightPane.setBorder(BorderFactory.createTitledBorder("All Drives")); + rightPane.add(new JScrollPane(table), BorderLayout.CENTER); + + JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, + leftPane, rightPane); + splitPane.setResizeWeight(0.45); // left pane gets 45 % initially + splitPane.setDividerSize(5); + splitPane.setBorder(null); + add(splitPane, BorderLayout.CENTER); + + // ── SOUTH — Test Directory row ──────────────────────────────────────── + JPanel southPanel = new JPanel(new BorderLayout(4, 0)); + southPanel.setBorder(BorderFactory.createTitledBorder("Test Directory")); + + pathField = new JTextField(); + pathField.setEditable(false); + southPanel.add(pathField, BorderLayout.CENTER); + + JPanel btnPanel = new JPanel(new BorderLayout(4, 0)); + browseButton = new JButton("Browse…"); + openButton = new JButton("Open"); + btnPanel.add(browseButton, BorderLayout.WEST); + btnPanel.add(openButton, BorderLayout.EAST); + southPanel.add(btnPanel, BorderLayout.EAST); + + add(southPanel, BorderLayout.SOUTH); + + // ── Wire listeners ──────────────────────────────────────────────────── + driveCombo.addActionListener(e -> { + if (!suppressComboEvents) applySelectedDrive(); + }); + + browseButton.addActionListener(e -> Gui.browseLocation()); + + openButton.addActionListener(e -> { + if (App.locationDir != null && App.locationDir.exists() + && Desktop.isDesktopSupported()) { + try { Desktop.getDesktop().open(App.locationDir); } + catch (IOException ex) { LOG.log(Level.WARNING, "open failed", ex); } + } + }); + + // Initial population + refreshTable(); + refreshDriveInfo(); + } + + // ----------------------------------------------------------------------- + // Drive Info pane builder + // ----------------------------------------------------------------------- + + /** + * Builds the left pane containing drive model / partition / usage info. + * Uses GridBagLayout so all labels are left-aligned with no dead space. + */ + private JPanel buildDriveInfoPane() { + infoModelLabel = new JLabel("Model: —"); + infoPartitionLabel = new JLabel("Partition: —"); + infoUsageLabel = new JLabel("Usage: —"); + accessLabel = new JLabel("Access: —"); + usageBar = new JProgressBar(0, 100); + usageBar.setStringPainted(true); + + JPanel inner = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.gridx = 0; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1.0; + gbc.anchor = GridBagConstraints.NORTHWEST; + gbc.insets = new Insets(3, 4, 3, 4); + + gbc.gridy = 0; inner.add(infoModelLabel, gbc); + gbc.gridy = 1; inner.add(infoPartitionLabel, gbc); + gbc.gridy = 2; inner.add(infoUsageLabel, gbc); + gbc.gridy = 3; inner.add(accessLabel, gbc); + gbc.gridy = 4; inner.add(usageBar, gbc); + + // Push content to the top + gbc.gridy = 5; + gbc.weighty = 1.0; + gbc.fill = GridBagConstraints.BOTH; + inner.add(new JPanel(), gbc); // filler + + JPanel wrapper = new JPanel(new BorderLayout()); + wrapper.setBorder(BorderFactory.createTitledBorder("Drive Info")); + wrapper.add(inner, BorderLayout.CENTER); + return wrapper; + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private void populateCombo() { + // Suppress events: removeAllItems() and addItem() both fire ActionEvent, + // which would invoke applySelectedDrive() and overwrite App.locationDir. + suppressComboEvents = true; + try { + driveCombo.removeAllItems(); + for (File root : File.listRoots()) { + if (root.getTotalSpace() == 0) continue; + driveCombo.addItem(new DriveEntry(root)); + } + } finally { + suppressComboEvents = false; + } + } + + private void syncComboToLocation() { + if (App.locationDir == null) return; + java.nio.file.Path locRoot = App.locationDir.toPath().getRoot(); + if (locRoot == null) return; + + for (int i = 0; i < driveCombo.getItemCount(); i++) { + DriveEntry entry = driveCombo.getItemAt(i); + if (entry.root.toPath().equals(locRoot) + || entry.root.getAbsolutePath().equalsIgnoreCase(locRoot.toString())) { + suppressComboEvents = true; + driveCombo.setSelectedIndex(i); + suppressComboEvents = false; + return; + } + } + } + + private void applySelectedDrive() { + DriveEntry entry = (DriveEntry) driveCombo.getSelectedItem(); + if (entry == null) return; + + File resolved = resolveLocationForRoot(entry.root); + if (resolved == null) { + accessLabel.setText("Access: ✗ No writable location found on this drive"); + accessLabel.setForeground(java.awt.Color.RED); + return; + } + + if (!DriveAccessChecker.validateTargetDirectory(resolved, true)) { + accessLabel.setText("Access: ✗ Cannot read/write test directory"); + accessLabel.setForeground(java.awt.Color.RED); + return; + } + + App.setLocationDir(resolved); + App.saveConfig(); + Gui.updateDiskInfo(); + } + + private static File resolveLocationForRoot(File root) { + File home = new File(System.getProperty("user.home", "")); + if (home.exists()) { + java.nio.file.Path homeRoot = home.toPath().getRoot(); + if (homeRoot != null && homeRoot.equals(root.toPath())) { + File candidate = new File(home, App.DATADIRNAME); + if (candidate.exists() ? candidate.canWrite() : home.canWrite()) { + return home; + } + } + } + if (root.canRead() && root.canWrite()) return root; + return null; + } + + private void refreshTable() { + tableModel.setRowCount(0); + for (File root : File.listRoots()) { + long total = root.getTotalSpace(); + long free = root.getFreeSpace(); + long used = total - free; + if (total == 0) continue; + + double totalGb = total / (double) App.GIGABYTE; + double usedGb = used / (double) App.GIGABYTE; + double freeGb = free / (double) App.GIGABYTE; + double pct = 100.0 * used / total; + + tableModel.addRow(new Object[]{ + root.getAbsolutePath(), + Math.round(totalGb * 10.0) / 10.0, + Math.round(usedGb * 10.0) / 10.0, + Math.round(freeGb * 10.0) / 10.0, + Math.round(pct * 10.0) / 10.0 + }); + } + } + + private void refreshDriveInfo() { + if (App.locationDir == null) return; + + // Update path field immediately on EDT + String testPath = (App.dataDir != null) + ? App.dataDir.getAbsolutePath() + : App.locationDir.getAbsolutePath() + File.separator + App.DATADIRNAME; + pathField.setText(testPath); + + // Reset info labels while loading + infoModelLabel.setText("Model: loading…"); + infoPartitionLabel.setText("Partition: loading…"); + infoUsageLabel.setText("Usage: loading…"); + accessLabel.setText("Access: loading…"); + usageBar.setValue(0); + usageBar.setString("…"); + + final File dir = App.locationDir; + + new SwingWorker() { + @Override + protected String[] doInBackground() { + String model = Util.getDriveModel(dir); + String partition = Util.getPartitionId(dir.toPath()); + DiskUsageInfo usage; + try { + usage = Util.getDiskUsage(dir.getAbsolutePath()); + } catch (IOException | InterruptedException ex) { + LOG.log(Level.WARNING, "getDiskUsage failed", ex); + usage = new DiskUsageInfo(); + } + return new String[]{ + model, partition, + usage.toDisplayString(), + String.valueOf(usage.percentUsed), + dir.canRead() ? "✓" : "✗", + dir.canWrite() ? "✓" : "✗" + }; + } + + @Override + protected void done() { + try { + String[] r = get(); + infoModelLabel.setText("Model: " + r[0]); + infoPartitionLabel.setText("Partition: " + r[1]); + infoUsageLabel.setText("Usage: " + r[2]); + + int pct = 0; + try { pct = Integer.parseInt(r[3]); } catch (NumberFormatException ignore) {} + usageBar.setValue(pct); + usageBar.setString(pct + "%"); + + boolean ok = "✓".equals(r[4]) && "✓".equals(r[5]); + accessLabel.setText("Access: Read " + r[4] + " Write " + r[5]); + accessLabel.setForeground(ok + ? new java.awt.Color(0, 180, 0) + : java.awt.Color.RED); + } catch (Exception ex) { + LOG.log(Level.WARNING, "refreshDriveInfo worker failed", ex); + } + } + }.execute(); + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /** + * Synchronises the panel with the current {@link App#locationDir}. + * Safe to call from any thread. + */ + public void refresh() { + Runnable r = () -> { + // Update the path field immediately — must be the very first thing + // so it reflects the new App.locationDir before any async work begins. + if (App.dataDir != null) { + pathField.setText(App.dataDir.getAbsolutePath()); + } else if (App.locationDir != null) { + pathField.setText(App.locationDir.getAbsolutePath() + + File.separator + App.DATADIRNAME); + } + populateCombo(); + syncComboToLocation(); + refreshTable(); + refreshDriveInfo(); + }; + if (SwingUtilities.isEventDispatchThread()) r.run(); + else SwingUtilities.invokeLater(r); + } +} diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index 3715246..480753f 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -91,6 +91,7 @@ public String toString() { public static SelectDriveFrame selFrame = null; public static BenchmarkPanel runPanel = null; public static SmartPanel smartPanel = null; + public static DrivesPanel drivesPanel = null; public static JProgressBar progressBar = null; // graph component public static JFreeChart chart; @@ -564,6 +565,9 @@ public static void updateLegendAndAxis(BenchmarkOperation o) { static public void updateDiskInfo() { mainFrame.setLocation(App.locationDir.getAbsolutePath()); chart.getTitle().setText(App.getDriveInfo()); + if (drivesPanel != null) { + drivesPanel.refresh(); + } } /** @@ -848,6 +852,7 @@ static void setWarmColorScheme() { } public static void browseLocation() { + selFrame = new SelectDriveFrame(); if (App.locationDir != null && App.locationDir.exists()) { selFrame.setInitDir(App.locationDir); } diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index 4416160..11f2b63 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -29,6 +29,11 @@ public final class MainFrame extends javax.swing.JFrame { public MainFrame() { initComponents(); + // The Drive Location tab is superseded by the Drives tab in the main + // navigation pane — remove it from the bottom tabbed pane at runtime. + // The NetBeans-generated field (locationPanel) is kept intact in the form. + tabbedPane.remove(locationPanel); + //for diagnostics //controlsPanel.setBackground(Color.blue); @@ -72,32 +77,36 @@ public MainFrame() { caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE); + // Build the left-side main navigation tab pane on all platforms. + // The Benchmark tab contains the control panel (left) + chart (right). + // The bottom tabbedPane (Benchmark Operations / Events / Drive Location) stays below. + javax.swing.JTabbedPane mainTabPane = new javax.swing.JTabbedPane(javax.swing.JTabbedPane.LEFT); + mainTabPane.putClientProperty("JTabbedPane.tabRotation", "auto"); + + // Drives tab — always visible on all platforms, shown first + Gui.drivesPanel = new DrivesPanel(); + mainTabPane.addTab("Drives", Gui.drivesPanel); + + JPanel benchTab = new JPanel(new BorderLayout()); + benchTab.add(bControlMountPanel, BorderLayout.WEST); + benchTab.add(cResultMountPanel, BorderLayout.CENTER); + mainTabPane.addTab("Benchmark", benchTab); + + // SMART tab — Linux only (requires smartctl / NVMe kernel support) if (App.isLinux()) { - // Build new left-side main navigation tab pane wrapping the top content area. - // The Benchmark tab contains the control panel (left) + chart (right). - // The bottom tabbedPane (Benchmark Operations / Events / Drive Location) stays below. - javax.swing.JTabbedPane mainTabPane = new javax.swing.JTabbedPane(javax.swing.JTabbedPane.LEFT); - mainTabPane.putClientProperty("JTabbedPane.tabRotation", "auto"); - - JPanel benchTab = new JPanel(new BorderLayout()); - benchTab.add(bControlMountPanel, BorderLayout.WEST); - benchTab.add(cResultMountPanel, BorderLayout.CENTER); - mainTabPane.addTab("Benchmark", benchTab); - - // SMART tab placeholder — ready for SMART data panel mainTabPane.addTab("SMART", Gui.smartPanel); + } - // Rebuild the content pane: mainTabPane fills the center; - // the original bottom tabs + progress bar go in a south panel. - getContentPane().removeAll(); - getContentPane().setLayout(new BorderLayout()); - getContentPane().add(mainTabPane, BorderLayout.CENTER); + // Rebuild the content pane: mainTabPane fills the center; + // the original bottom tabs + progress bar go in a south panel. + getContentPane().removeAll(); + getContentPane().setLayout(new BorderLayout()); + getContentPane().add(mainTabPane, BorderLayout.CENTER); - JPanel southPanel = new JPanel(new BorderLayout()); - southPanel.add(tabbedPane, BorderLayout.CENTER); - southPanel.add(progressPanel, BorderLayout.SOUTH); - getContentPane().add(southPanel, BorderLayout.SOUTH); - } + JPanel southPanel = new JPanel(new BorderLayout()); + southPanel.add(tabbedPane, BorderLayout.CENTER); + southPanel.add(progressPanel, BorderLayout.SOUTH); + getContentPane().add(southPanel, BorderLayout.SOUTH); } From fd144f301b42154ee0dba978cdba25e4397f2069 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sun, 14 Jun 2026 21:10:24 -0700 Subject: [PATCH 07/22] #61 SMART Data Display --- jdm-core/src/main/java/jdiskmark/Gui.java | 61 ++++ .../src/main/java/jdiskmark/MainFrame.java | 9 + jdm-core/src/main/java/jdiskmark/Smart.java | 138 +++++++++ .../src/main/java/jdiskmark/SmartPanel.java | 284 +++++++++++++----- 4 files changed, 410 insertions(+), 82 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index 480753f..a043c66 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -17,8 +17,12 @@ import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.File; +import java.nio.file.Path; import java.text.NumberFormat; import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; import javax.swing.JOptionPane; import javax.swing.JProgressBar; import javax.swing.SwingWorker.StateValue; @@ -562,12 +566,69 @@ public static void updateLegendAndAxis(BenchmarkOperation o) { msAxis.setVisible(showDriveAccess); } + private static final Logger SMART_LOG = Logger.getLogger(Gui.class.getName()); + static public void updateDiskInfo() { mainFrame.setLocation(App.locationDir.getAbsolutePath()); chart.getTitle().setText(App.getDriveInfo()); if (drivesPanel != null) { drivesPanel.refresh(); } + // SMART data is fetched lazily via refreshSmartTab(), which is called + // by the tab-change listener in MainFrame only when the user selects + // the SMART tab. This prevents the pkexec password dialog from + // appearing before the application window is visible. + } + + /** + * Fetches fresh SMART data for the current drive in a background thread + * and populates the SMART panel when done. Triggers the pkexec password + * prompt on the very first call (or after the privileged shell dies). + * + *

Safe to call from the EDT; the privileged I/O runs off-thread. + * No-op if SMART is disabled, the OS is not Linux, or the panel is null. + */ + static public void refreshSmartTab() { + if (!Smart.smartEnable || !App.isLinux() || smartPanel == null + || App.locationDir == null) { + return; + } + final File locDir = App.locationDir; + new javax.swing.SwingWorker() { + @Override + protected Smart doInBackground() { + try { + Path path = locDir.toPath(); + String partition = UtilOs.getPartitionFromFilePathLinux(path); + List devices = + UtilOs.getDeviceNamesFromPartitionLinux(partition); + if (devices == null || devices.isEmpty()) { + SMART_LOG.warning("refreshSmartTab: no device for " + locDir); + return null; + } + String deviceName = devices.get(0); + if (Smart.process == null || !Smart.process.isAlive()) { + Smart.startPrivilegedShell(); + Smart.startHeartbeat(); + } + return Smart.getSmart(deviceName); + } catch (Exception ex) { + SMART_LOG.log(Level.WARNING, "refreshSmartTab: SMART fetch failed", ex); + return null; + } + } + + @Override + protected void done() { + try { + Smart data = get(); + if (data != null) smartPanel.populate(data); + else smartPanel.clear(); + } catch (Exception ex) { + SMART_LOG.log(Level.WARNING, "refreshSmartTab: panel update failed", ex); + } + } + }.execute(); } /** diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index 11f2b63..6c5f981 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -97,6 +97,15 @@ public MainFrame() { mainTabPane.addTab("SMART", Gui.smartPanel); } + // Fetch SMART data only when the user selects the SMART tab. + // This keeps the pkexec password dialog from appearing at startup. + mainTabPane.addChangeListener(e -> { + int sel = mainTabPane.getSelectedIndex(); + if (sel >= 0 && "SMART".equals(mainTabPane.getTitleAt(sel))) { + Gui.refreshSmartTab(); + } + }); + // Rebuild the content pane: mainTabPane fills the center; // the original bottom tabs + progress bar go in a south panel. getContentPane().removeAll(); diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index 72bd8df..03cb070 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -241,6 +241,32 @@ public static void logSmart(Smart smart) { @JsonProperty("nvme_smart_health_information_log") private NvmeHealthLog nvmeHealthLog; + // ── NVMe-specific info-section fields ────────────────────────────────── + + @JsonProperty("nvme_pci_vendor") + private NvmePciVendor nvmePciVendor; + + @JsonProperty("nvme_ieee_oui_identifier") + private Long nvmeIeeeOuiIdentifier; + + @JsonProperty("nvme_total_capacity") + private Long nvmeTotalCapacity; + + @JsonProperty("nvme_unallocated_capacity") + private Long nvmeUnallocatedCapacity; + + @JsonProperty("nvme_controller_id") + private Integer nvmeControllerId; + + @JsonProperty("nvme_version") + private NvmeVersionInfo nvmeVersion; + + @JsonProperty("nvme_number_of_namespaces") + private Integer nvmeNumberOfNamespaces; + + @JsonProperty("local_time") + private LocalTimeInfo localTime; + // ------------------------------------------------------------------------- // Factory / parsing // ------------------------------------------------------------------------- @@ -306,10 +332,110 @@ public static Smart fromJson(String json) throws IOException { public NvmeHealthLog getNvmeHealthLog() { return nvmeHealthLog; } public void setNvmeHealthLog(NvmeHealthLog nvmeHealthLog) { this.nvmeHealthLog = nvmeHealthLog; } + public NvmePciVendor getNvmePciVendor() { return nvmePciVendor; } + public void setNvmePciVendor(NvmePciVendor nvmePciVendor) { this.nvmePciVendor = nvmePciVendor; } + + public Long getNvmeIeeeOuiIdentifier() { return nvmeIeeeOuiIdentifier; } + public void setNvmeIeeeOuiIdentifier(Long nvmeIeeeOuiIdentifier) { this.nvmeIeeeOuiIdentifier = nvmeIeeeOuiIdentifier; } + + public Long getNvmeTotalCapacity() { return nvmeTotalCapacity; } + public void setNvmeTotalCapacity(Long nvmeTotalCapacity) { this.nvmeTotalCapacity = nvmeTotalCapacity; } + + public Long getNvmeUnallocatedCapacity() { return nvmeUnallocatedCapacity; } + public void setNvmeUnallocatedCapacity(Long nvmeUnallocatedCapacity) { this.nvmeUnallocatedCapacity = nvmeUnallocatedCapacity; } + + public Integer getNvmeControllerId() { return nvmeControllerId; } + public void setNvmeControllerId(Integer nvmeControllerId) { this.nvmeControllerId = nvmeControllerId; } + + public NvmeVersionInfo getNvmeVersion() { return nvmeVersion; } + public void setNvmeVersion(NvmeVersionInfo nvmeVersion) { this.nvmeVersion = nvmeVersion; } + + public Integer getNvmeNumberOfNamespaces() { return nvmeNumberOfNamespaces; } + public void setNvmeNumberOfNamespaces(Integer nvmeNumberOfNamespaces) { this.nvmeNumberOfNamespaces = nvmeNumberOfNamespaces; } + + public LocalTimeInfo getLocalTime() { return localTime; } + public void setLocalTime(LocalTimeInfo localTime) { this.localTime = localTime; } + // ========================================================================= // Nested classes // ========================================================================= + // ------------------------------------------------------------------------- + // NVMe PCI Vendor + // ------------------------------------------------------------------------- + + /** Represents the {@code nvme_pci_vendor} block (id, subsystem_id). */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class NvmePciVendor { + + @JsonProperty("id") + private Integer id; + + @JsonProperty("subsystem_id") + private Integer subsystemId; + + public NvmePciVendor() {} + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + + public Integer getSubsystemId() { return subsystemId; } + public void setSubsystemId(Integer subsystemId) { this.subsystemId = subsystemId; } + + /** Returns a human-readable {@code "0x1234 / 0x5678"} string, or {@code null}. */ + public String getDisplayString() { + if (id == null) return null; + String sub = subsystemId != null ? " / 0x" + Integer.toHexString(subsystemId).toUpperCase() : ""; + return "0x" + Integer.toHexString(id).toUpperCase() + sub; + } + } + + // ------------------------------------------------------------------------- + // NVMe version + // ------------------------------------------------------------------------- + + /** Represents the {@code nvme_version} block. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class NvmeVersionInfo { + + @JsonProperty("string") + private String string; + + @JsonProperty("value") + private Integer value; + + public NvmeVersionInfo() {} + + public String getString() { return string; } + public void setString(String string) { this.string = string; } + + public Integer getValue() { return value; } + public void setValue(Integer value) { this.value = value; } + } + + // ------------------------------------------------------------------------- + // Local time + // ------------------------------------------------------------------------- + + /** Represents the {@code local_time} block. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class LocalTimeInfo { + + @JsonProperty("time_t") + private Long timeT; + + @JsonProperty("asctime") + private String asctime; + + public LocalTimeInfo() {} + + public Long getTimeT() { return timeT; } + public void setTimeT(Long timeT) { this.timeT = timeT; } + + public String getAsctime() { return asctime; } + public void setAsctime(String asctime) { this.asctime = asctime; } + } + // ------------------------------------------------------------------------- // smartctl tool info // ------------------------------------------------------------------------- @@ -745,6 +871,12 @@ public static class NvmeHealthLog { @JsonProperty("critical_comp_time") private Long criticalCompTime; + @JsonProperty("temperature_sensor_1") + private Integer temperatureSensor1; + + @JsonProperty("temperature_sensor_2") + private Integer temperatureSensor2; + public NvmeHealthLog() {} public Integer getCriticalWarning() { return criticalWarning; } @@ -798,6 +930,12 @@ public NvmeHealthLog() {} public Long getCriticalCompTime() { return criticalCompTime; } public void setCriticalCompTime(Long criticalCompTime) { this.criticalCompTime = criticalCompTime; } + public Integer getTemperatureSensor1() { return temperatureSensor1; } + public void setTemperatureSensor1(Integer temperatureSensor1) { this.temperatureSensor1 = temperatureSensor1; } + + public Integer getTemperatureSensor2() { return temperatureSensor2; } + public void setTemperatureSensor2(Integer temperatureSensor2) { this.temperatureSensor2 = temperatureSensor2; } + /** * Returns {@code true} if {@code critical_warning} is non-zero, * indicating a health issue that needs attention. diff --git a/jdm-core/src/main/java/jdiskmark/SmartPanel.java b/jdm-core/src/main/java/jdiskmark/SmartPanel.java index a398f54..52c6fbe 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SmartPanel.java @@ -1,5 +1,6 @@ package jdiskmark; +import java.awt.BorderLayout; import java.awt.Color; import java.awt.Font; import java.util.List; @@ -16,20 +17,24 @@ * Displays parsed S.M.A.R.T. data in the main "SMART" tab. * * Call {@link #populate(Smart)} on the Event Dispatch Thread (or from - * any thread — it marshals to the EDT internally) after the benchmark runner - * has retrieved and parsed the {@code smartctl} JSON output. + * any thread — it marshals to the EDT internally) after SMART data has + * been retrieved and parsed from {@code smartctl --json -a}. * - *

Layout sections: + *

Layout sections (scrollable): *

    - *
  • Drive Info – model, serial, firmware, capacity, protocol
  • - *
  • Health – SMART status, temperature, power-on hours, power cycles
  • - *
  • NVMe Health Log – available spare, % used, data written/read, errors (NVMe only)
  • - *
  • ATA Attributes – scrollable table of all ATA SMART attributes (SATA only)
  • + *
  • Drive Info – model, serial, firmware, capacity, protocol
  • + *
  • NVMe Device Details – version, controller ID, OUI, capacities (NVMe only)
  • + *
  • Health – SMART status, temperature, power-on hours, power cycles
  • + *
  • NVMe Health Log – spare, % used, data written/read, errors, temp sensors
  • + *
  • ATA Attributes – scrollable attribute table (SATA only)
  • *
* * @author jasmine */ -public class SmartPanel extends javax.swing.JPanel { +public class SmartPanel extends JPanel { + + // ── Column spec used by every section — keeps labels/values aligned ────── + private static final String COL_SPEC = "[170][grow][170][grow]"; // ------------------------------------------------------------------------- // Drive Info labels @@ -40,6 +45,19 @@ public class SmartPanel extends javax.swing.JPanel { private final JLabel capacityValueLabel = value("-"); private final JLabel protocolValueLabel = value("-"); + // ------------------------------------------------------------------------- + // NVMe Device Details labels + // ------------------------------------------------------------------------- + private final JLabel nvmeVersionValueLabel = value("-"); + private final JLabel nvmeControllerIdValueLabel = value("-"); + private final JLabel nvmeOuiValueLabel = value("-"); + private final JLabel nvmeVendorValueLabel = value("-"); + private final JLabel nvmeTotalCapValueLabel = value("-"); + private final JLabel nvmeUnallocCapValueLabel = value("-"); + private final JLabel nvmeNsCountValueLabel = value("-"); + private final JLabel localTimeValueLabel = value("-"); + private JPanel nvmeDevSection; + // ------------------------------------------------------------------------- // Health labels // ------------------------------------------------------------------------- @@ -49,7 +67,7 @@ public class SmartPanel extends javax.swing.JPanel { private final JLabel powerCyclesValueLabel = value("-"); // ------------------------------------------------------------------------- - // NVMe-specific labels + // NVMe Health Log labels // ------------------------------------------------------------------------- private final JLabel spareValueLabel = value("-"); private final JLabel usedPctValueLabel = value("-"); @@ -59,6 +77,8 @@ public class SmartPanel extends javax.swing.JPanel { private final JLabel errLogValueLabel = value("-"); private final JLabel warnTempValueLabel = value("-"); private final JLabel critCompValueLabel = value("-"); + private final JLabel tempSensor1ValueLabel = value("-"); + private final JLabel tempSensor2ValueLabel = value("-"); private JPanel nvmeSection; // ------------------------------------------------------------------------- @@ -73,7 +93,8 @@ public class SmartPanel extends javax.swing.JPanel { // ------------------------------------------------------------------------- public SmartPanel() { - // ATA table model + super(new BorderLayout()); + ataModel = new DefaultTableModel( new String[]{"ID", "Attribute Name", "Value", "Worst", "Threshold", "Raw", "Status"}, 0 @@ -91,74 +112,113 @@ public SmartPanel() { ataTable.getColumnModel().getColumn(5).setPreferredWidth(80); ataTable.getColumnModel().getColumn(6).setPreferredWidth(60); - buildLayout(); + // Inner content panel — sections are added here + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new MigLayout("insets 12, fillx", "[grow]", "[]8[]8[]8[]8[]")); + + buildLayout(contentPanel); + + // Wrap in a scroll pane so the tab is always scrollable + JScrollPane scroller = new JScrollPane(contentPanel, + JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, + JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + scroller.setBorder(null); + scroller.getVerticalScrollBar().setUnitIncrement(16); + add(scroller, BorderLayout.CENTER); } // ------------------------------------------------------------------------- // Layout // ------------------------------------------------------------------------- - private void buildLayout() { - setLayout(new MigLayout("insets 12, fillx", "[grow]", "[]8[]8[]8[]")); + private void buildLayout(JPanel p) { // --- Drive Info section --- JPanel driveSection = section("Drive Info"); - driveSection.setLayout(new MigLayout("insets 8, wrap 4", "[120][grow][120][grow]")); + driveSection.setLayout(new MigLayout("insets 8, wrap 4", COL_SPEC)); driveSection.add(label("Model:")); driveSection.add(modelValueLabel, "growx"); driveSection.add(label("Protocol:")); - driveSection.add(protocolValueLabel, "growx, wrap"); + driveSection.add(protocolValueLabel, "growx"); driveSection.add(label("Serial:")); driveSection.add(serialValueLabel, "growx"); driveSection.add(label("Firmware:")); - driveSection.add(firmwareValueLabel, "growx, wrap"); + driveSection.add(firmwareValueLabel, "growx"); driveSection.add(label("Capacity:")); driveSection.add(capacityValueLabel, "growx, span 3"); - add(driveSection, "growx, wrap"); + p.add(driveSection, "growx, wrap"); + + // --- NVMe Device Details section --- + // Shown with dash placeholders on startup; populated once SMART data arrives. + // Hidden automatically when an ATA drive is detected. + nvmeDevSection = section("NVMe Device Details"); + nvmeDevSection.setLayout(new MigLayout("insets 8, wrap 4", COL_SPEC)); + nvmeDevSection.add(label("NVMe Version:")); + nvmeDevSection.add(nvmeVersionValueLabel, "growx"); + nvmeDevSection.add(label("Controller ID:")); + nvmeDevSection.add(nvmeControllerIdValueLabel, "growx"); + nvmeDevSection.add(label("PCI Vendor/Subsystem:")); + nvmeDevSection.add(nvmeVendorValueLabel, "growx"); + nvmeDevSection.add(label("IEEE OUI:")); + nvmeDevSection.add(nvmeOuiValueLabel, "growx"); + nvmeDevSection.add(label("Total NVM Capacity:")); + nvmeDevSection.add(nvmeTotalCapValueLabel, "growx"); + nvmeDevSection.add(label("Unallocated NVM:")); + nvmeDevSection.add(nvmeUnallocCapValueLabel, "growx"); + nvmeDevSection.add(label("Namespaces:")); + nvmeDevSection.add(nvmeNsCountValueLabel, "growx"); + nvmeDevSection.add(label("Local Time:")); + nvmeDevSection.add(localTimeValueLabel, "growx, span 3"); + nvmeDevSection.setVisible(true); // visible with dashes before any SMART query + p.add(nvmeDevSection, "growx, wrap"); // --- Health section --- JPanel healthSection = section("Health"); - healthSection.setLayout(new MigLayout("insets 8, wrap 4", "[120][grow][120][grow]")); + healthSection.setLayout(new MigLayout("insets 8, wrap 4", COL_SPEC)); healthSection.add(label("SMART Status:")); healthSection.add(statusValueLabel, "growx"); healthSection.add(label("Temperature:")); - healthSection.add(tempValueLabel, "growx, wrap"); + healthSection.add(tempValueLabel, "growx"); healthSection.add(label("Power-On Hours:")); healthSection.add(powerOnValueLabel, "growx"); healthSection.add(label("Power Cycles:")); healthSection.add(powerCyclesValueLabel, "growx"); - add(healthSection, "growx, wrap"); + p.add(healthSection, "growx, wrap"); - // --- NVMe Health Log section (hidden until populated) --- - nvmeSection = section("NVMe Health Log"); - nvmeSection.setLayout(new MigLayout("insets 8, wrap 4", "[150][grow][150][grow]")); + // --- NVMe Health Log section --- + nvmeSection = section("NVMe Health Log (Log 0x02)"); + nvmeSection.setLayout(new MigLayout("insets 8, wrap 4", COL_SPEC)); nvmeSection.add(label("Available Spare:")); - nvmeSection.add(spareValueLabel, "growx"); + nvmeSection.add(spareValueLabel, "growx"); nvmeSection.add(label("% Used (PE cycles):")); - nvmeSection.add(usedPctValueLabel, "growx, wrap"); + nvmeSection.add(usedPctValueLabel, "growx"); nvmeSection.add(label("Data Written:")); - nvmeSection.add(writtenValueLabel, "growx"); + nvmeSection.add(writtenValueLabel, "growx"); nvmeSection.add(label("Data Read:")); - nvmeSection.add(readValueLabel, "growx, wrap"); + nvmeSection.add(readValueLabel, "growx"); nvmeSection.add(label("Media Errors:")); - nvmeSection.add(mediaErrValueLabel, "growx"); + nvmeSection.add(mediaErrValueLabel, "growx"); nvmeSection.add(label("Error Log Entries:")); - nvmeSection.add(errLogValueLabel, "growx, wrap"); - nvmeSection.add(label("Warning Temp Time:")); - nvmeSection.add(warnTempValueLabel, "growx"); - nvmeSection.add(label("Critical Comp Time:")); - nvmeSection.add(critCompValueLabel, "growx"); - nvmeSection.setVisible(true); // shown by default with dash placeholders; populated after auth - add(nvmeSection, "growx, wrap"); - - // --- ATA Attributes section (hidden until populated) --- + nvmeSection.add(errLogValueLabel, "growx"); + nvmeSection.add(label("Warn Temp Time:")); + nvmeSection.add(warnTempValueLabel, "growx"); + nvmeSection.add(label("Crit Comp Time:")); + nvmeSection.add(critCompValueLabel, "growx"); + nvmeSection.add(label("Temp Sensor 1:")); + nvmeSection.add(tempSensor1ValueLabel, "growx"); + nvmeSection.add(label("Temp Sensor 2:")); + nvmeSection.add(tempSensor2ValueLabel, "growx"); + nvmeSection.setVisible(true); + p.add(nvmeSection, "growx, wrap"); + + // --- ATA Attributes section (hidden until ATA data is present) --- ataSection = section("ATA SMART Attributes"); ataSection.setLayout(new MigLayout("insets 8, fill", "[grow]", "[grow]")); - JScrollPane scrollPane = new JScrollPane(ataTable); - scrollPane.setPreferredSize(new java.awt.Dimension(600, 200)); - ataSection.add(scrollPane, "grow"); + JScrollPane ataScroll = new JScrollPane(ataTable); + ataScroll.setPreferredSize(new java.awt.Dimension(600, 200)); + ataSection.add(ataScroll, "grow"); ataSection.setVisible(false); - add(ataSection, "growx, wrap"); + p.add(ataSection, "growx, wrap"); } // ------------------------------------------------------------------------- @@ -178,6 +238,7 @@ public void populate(Smart data) { return; } fillDriveInfo(data); + fillNvmeDevDetails(data); fillHealth(data); fillNvme(data.getNvmeHealthLog()); fillAtaAttributes(data.getAtaSmartAttributes()); @@ -186,23 +247,29 @@ public void populate(Smart data) { }); } - /** Resets all fields to their default placeholder values. */ + /** Resets all fields to dash placeholders and restores default visibility. */ public void clear() { SwingUtilities.invokeLater(() -> { for (JLabel l : new JLabel[]{ modelValueLabel, serialValueLabel, firmwareValueLabel, capacityValueLabel, protocolValueLabel, + nvmeVersionValueLabel, nvmeControllerIdValueLabel, + nvmeOuiValueLabel, nvmeVendorValueLabel, + nvmeTotalCapValueLabel, nvmeUnallocCapValueLabel, + nvmeNsCountValueLabel, localTimeValueLabel, statusValueLabel, tempValueLabel, powerOnValueLabel, powerCyclesValueLabel, spareValueLabel, usedPctValueLabel, writtenValueLabel, readValueLabel, mediaErrValueLabel, errLogValueLabel, - warnTempValueLabel, critCompValueLabel + warnTempValueLabel, critCompValueLabel, + tempSensor1ValueLabel, tempSensor2ValueLabel }) { l.setText("-"); l.setForeground(null); } ataModel.setRowCount(0); - nvmeSection.setVisible(true); // keep visible so placeholders remain shown + nvmeDevSection.setVisible(true); // keep visible with dashes + nvmeSection.setVisible(true); ataSection.setVisible(false); revalidate(); repaint(); @@ -226,36 +293,85 @@ private void fillDriveInfo(Smart d) { } } + private void fillNvmeDevDetails(Smart d) { + boolean hasNvmeInfo = d.getNvmeVersion() != null + || d.getNvmeControllerId() != null + || d.getNvmeTotalCapacity() != null + || d.getNvmeNumberOfNamespaces() != null; + + // Hide for confirmed ATA drives (ATA attributes present, no NVMe info) + boolean isAta = !hasNvmeInfo + && d.getAtaSmartAttributes() != null + && d.getAtaSmartAttributes().getTable() != null + && !d.getAtaSmartAttributes().getTable().isEmpty(); + + if (isAta) { + nvmeDevSection.setVisible(false); + return; + } + + nvmeDevSection.setVisible(true); + + if (d.getNvmeVersion() != null && d.getNvmeVersion().getString() != null) { + nvmeVersionValueLabel.setText(d.getNvmeVersion().getString()); + } + if (d.getNvmeControllerId() != null) { + nvmeControllerIdValueLabel.setText(String.valueOf(d.getNvmeControllerId())); + } + if (d.getNvmePciVendor() != null) { + String s = d.getNvmePciVendor().getDisplayString(); + nvmeVendorValueLabel.setText(s != null ? s : "-"); + } + if (d.getNvmeIeeeOuiIdentifier() != null) { + nvmeOuiValueLabel.setText( + "0x" + Long.toHexString(d.getNvmeIeeeOuiIdentifier()).toUpperCase()); + } + if (d.getNvmeTotalCapacity() != null) { + double gb = Math.round(d.getNvmeTotalCapacity() / 1_000_000_000.0 * 100.0) / 100.0; + nvmeTotalCapValueLabel.setText(gb + " GB"); + } + if (d.getNvmeUnallocatedCapacity() != null) { + double gb = Math.round(d.getNvmeUnallocatedCapacity() / 1_000_000_000.0 * 100.0) / 100.0; + nvmeUnallocCapValueLabel.setText(gb + " GB"); + } + if (d.getNvmeNumberOfNamespaces() != null) { + nvmeNsCountValueLabel.setText(String.valueOf(d.getNvmeNumberOfNamespaces())); + } + if (d.getLocalTime() != null && d.getLocalTime().getAsctime() != null) { + localTimeValueLabel.setText(d.getLocalTime().getAsctime()); + } + } + private void fillHealth(Smart d) { - // SMART status if (d.getSmartStatus() != null) { boolean passed = Boolean.TRUE.equals(d.getSmartStatus().isPassed()); statusValueLabel.setText(passed ? "PASSED ✔" : "FAILED ✘"); statusValueLabel.setForeground(passed ? new Color(0x4CAF50) : new Color(0xF44336)); } - // Temperature + // Temperature — prefer top-level block, fall back to NVMe health log if (d.getTemperature() != null && d.getTemperature().getCurrent() != null) { - int temp = d.getTemperature().getCurrent(); - tempValueLabel.setText(temp + " °C"); - // colour-code: green < 45, amber < 60, red ≥ 60 - if (temp >= 60) { - tempValueLabel.setForeground(new Color(0xF44336)); - } else if (temp >= 45) { - tempValueLabel.setForeground(new Color(0xFF9800)); - } else { - tempValueLabel.setForeground(new Color(0x4CAF50)); - } + int t = d.getTemperature().getCurrent(); + tempValueLabel.setText(t + " °C"); + tempValueLabel.setForeground(tempColor(t)); + } else if (d.getNvmeHealthLog() != null && d.getNvmeHealthLog().getTemperature() != null) { + int t = d.getNvmeHealthLog().getTemperature(); + tempValueLabel.setText(t + " °C"); + tempValueLabel.setForeground(tempColor(t)); } - // Power-on hours + // Power-on hours — prefer top-level, fall back to NVMe health log if (d.getPowerOnTime() != null && d.getPowerOnTime().getHours() != null) { powerOnValueLabel.setText(d.getPowerOnTime().getHours() + " h"); + } else if (d.getNvmeHealthLog() != null && d.getNvmeHealthLog().getPowerOnHours() != null) { + powerOnValueLabel.setText(d.getNvmeHealthLog().getPowerOnHours() + " h"); } - // Power cycles + // Power cycles — prefer top-level, fall back to NVMe health log if (d.getPowerCycleCount() != null) { powerCyclesValueLabel.setText(String.valueOf(d.getPowerCycleCount())); + } else if (d.getNvmeHealthLog() != null && d.getNvmeHealthLog().getPowerCycles() != null) { + powerCyclesValueLabel.setText(String.valueOf(d.getNvmeHealthLog().getPowerCycles())); } } @@ -266,22 +382,26 @@ private void fillNvme(Smart.NvmeHealthLog nvme) { } nvmeSection.setVisible(true); - setNvmeField(spareValueLabel, nvme.getAvailableSpare(), "%", + setNvmeField(spareValueLabel, nvme.getAvailableSpare(), "%", nvme.getAvailableSpareThreshold(), true); - setNvmeField(usedPctValueLabel, nvme.getPercentageUsed(), "%", null, false); - writtenValueLabel.setText(nvme.getDataWrittenGb() + " GB (" + nvme.getDataUnitsWritten() + " units)"); - readValueLabel.setText(nvme.getDataReadGb() + " GB (" + nvme.getDataUnitsRead() + " units)"); + setNvmeField(usedPctValueLabel, nvme.getPercentageUsed(), "%", null, false); + + writtenValueLabel.setText(nvme.getDataWrittenGb() + + " GB (" + nvme.getDataUnitsWritten() + " units)"); + readValueLabel.setText(nvme.getDataReadGb() + + " GB (" + nvme.getDataUnitsRead() + " units)"); - // Error counts — colour red on non-zero - setCountField(mediaErrValueLabel, nvme.getMediaErrors()); - setCountField(errLogValueLabel, nvme.getNumErrLogEntries()); + setCountField(mediaErrValueLabel, nvme.getMediaErrors()); + setCountField(errLogValueLabel, nvme.getNumErrLogEntries()); warnTempValueLabel.setText(nvme.getWarningTempTime() != null ? nvme.getWarningTempTime() + " min" : "-"); critCompValueLabel.setText(nvme.getCriticalCompTime() != null ? nvme.getCriticalCompTime() + " min" : "-"); - // Flag critical warning + setTempSensorField(tempSensor1ValueLabel, nvme.getTemperatureSensor1()); + setTempSensorField(tempSensor2ValueLabel, nvme.getTemperatureSensor2()); + if (nvme.hasCriticalWarning()) { statusValueLabel.setText("CRITICAL WARNING (" + nvme.getCriticalWarning() + ") ✘"); statusValueLabel.setForeground(new Color(0xF44336)); @@ -300,13 +420,9 @@ private void fillAtaAttributes(Smart.AtaSmartAttributes ata) { String raw = attr.getRaw() != null ? attr.getRaw().getString() : "-"; String status = attr.isFailing() ? "FAILING ✘" : "OK"; ataModel.addRow(new Object[]{ - attr.getId(), - orDash(attr.getName()), - attr.getValue(), - attr.getWorst(), - attr.getThresh(), - raw, - status + attr.getId(), orDash(attr.getName()), + attr.getValue(), attr.getWorst(), attr.getThresh(), + raw, status }); } } @@ -315,7 +431,18 @@ private void fillAtaAttributes(Smart.AtaSmartAttributes ata) { // Small helpers // ------------------------------------------------------------------------- - /** Labels a field value with optional threshold colouring. */ + private static Color tempColor(int tempC) { + if (tempC >= 60) return new Color(0xF44336); + if (tempC >= 45) return new Color(0xFF9800); + return new Color(0x4CAF50); + } + + private void setTempSensorField(JLabel lbl, Integer tempC) { + if (tempC == null) { lbl.setText("-"); return; } + lbl.setText(tempC + " °C"); + lbl.setForeground(tempColor(tempC)); + } + private void setNvmeField(JLabel lbl, Integer value, String suffix, Integer threshold, boolean higherIsBetter) { if (value == null) { lbl.setText("-"); return; } @@ -326,31 +453,24 @@ private void setNvmeField(JLabel lbl, Integer value, String suffix, } } - /** Sets a count field to red on non-zero. */ private void setCountField(JLabel lbl, Long value) { if (value == null) { lbl.setText("-"); return; } lbl.setText(String.valueOf(value)); lbl.setForeground(value > 0 ? new Color(0xF44336) : null); } - /** Returns {@code s} if non-null/non-empty, else {@code "-"}. */ private static String orDash(String s) { return (s != null && !s.isBlank()) ? s : "-"; } - /** Creates a right-aligned bold key label. */ private static JLabel label(String text) { JLabel l = new JLabel(text); l.setFont(l.getFont().deriveFont(Font.BOLD)); return l; } - /** Creates a left-aligned plain value label. */ - private static JLabel value(String text) { - return new JLabel(text); - } + private static JLabel value(String text) { return new JLabel(text); } - /** Creates a titled, etched-border section panel. */ private static JPanel section(String title) { JPanel p = new JPanel(); p.setBorder(BorderFactory.createTitledBorder( From 81b284ac1a3c2afb42cb0f7a32869767d41c5f1a Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sun, 21 Jun 2026 21:58:22 -0700 Subject: [PATCH 08/22] #61 Draft SMART on Ubuntu --- jdm-core/src/main/java/jdiskmark/Gui.java | 106 +++++- .../src/main/java/jdiskmark/MainFrame.java | 43 ++- jdm-core/src/main/java/jdiskmark/Smart.java | 55 ++- .../src/main/java/jdiskmark/SmartPanel.java | 358 +++++++++++++++++- .../java/jdiskmark/SmartReportsPanel.java | 238 ++++++++++++ .../main/java/jdiskmark/SmartSnapshot.java | 295 +++++++++++++++ 6 files changed, 1060 insertions(+), 35 deletions(-) create mode 100644 jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java create mode 100644 jdm-core/src/main/java/jdiskmark/SmartSnapshot.java diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index a043c66..4da477c 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -96,7 +96,18 @@ public String toString() { public static BenchmarkPanel runPanel = null; public static SmartPanel smartPanel = null; public static DrivesPanel drivesPanel = null; + public static SmartReportsPanel smartReportsPanel = null; + public static javax.swing.JTabbedPane mainTabPane = null; public static JProgressBar progressBar = null; + // last SMART data captured via refreshSmartTab() — used by Save Snapshot button + public static Smart lastSmartData = null; + public static String lastSmartDeviceName = null; + /** + * {@code true} while the SMART tab is displaying a stored {@link SmartSnapshot} + * rather than freshly fetched live data. Cleared when {@link #refreshSmartTab()} + * starts a new live query; set by {@link #loadSnapshot(SmartSnapshot)}. + */ + public static boolean viewingSnapshot = false; // graph component public static JFreeChart chart; public static NumberAxis msAxis, bwAxis, sampleAxis; @@ -587,13 +598,19 @@ static public void updateDiskInfo() { * *

Safe to call from the EDT; the privileged I/O runs off-thread. * No-op if SMART is disabled, the OS is not Linux, or the panel is null. + * Called by the "Run SMART" button in {@link SmartPanel}. */ static public void refreshSmartTab() { if (!Smart.smartEnable || !App.isLinux() || smartPanel == null || App.locationDir == null) { return; } + // A live query is starting — we are no longer viewing a stored snapshot. + viewingSnapshot = false; final File locDir = App.locationDir; + // Single-element array so doInBackground() can share the device name + // with done() without a field (anonymous SwingWorker limitation). + final String[] deviceRef = {null}; new javax.swing.SwingWorker() { @Override protected Smart doInBackground() { @@ -606,12 +623,12 @@ protected Smart doInBackground() { SMART_LOG.warning("refreshSmartTab: no device for " + locDir); return null; } - String deviceName = devices.get(0); + deviceRef[0] = devices.get(0); if (Smart.process == null || !Smart.process.isAlive()) { Smart.startPrivilegedShell(); Smart.startHeartbeat(); } - return Smart.getSmart(deviceName); + return Smart.getSmart(deviceRef[0]); } catch (Exception ex) { SMART_LOG.log(Level.WARNING, "refreshSmartTab: SMART fetch failed", ex); return null; @@ -622,14 +639,95 @@ protected Smart doInBackground() { protected void done() { try { Smart data = get(); - if (data != null) smartPanel.populate(data); - else smartPanel.clear(); + String device = deviceRef[0]; + if (data != null) { + // Store for the Save Snapshot button + lastSmartData = data; + lastSmartDeviceName = device; + smartPanel.populate(data); + smartPanel.onDataLoaded(device != null ? device : "unknown"); + } else { + lastSmartData = null; + lastSmartDeviceName = null; + smartPanel.clear(); + } } catch (Exception ex) { SMART_LOG.log(Level.WARNING, "refreshSmartTab: panel update failed", ex); } } }.execute(); } + + /** + * Loads a stored {@link SmartSnapshot} into the SMART tab and switches + * focus to it. Sets {@link #viewingSnapshot} to {@code true}. + * + *

If the snapshot contains a {@code rawJson} CLOB (saved with the + * current schema), the full {@link Smart} object is re-parsed so that + * every section of the SMART tab (ATA attributes, NVMe device details, + * endurance, etc.) is replayed exactly as it appeared live. For older + * snapshots without {@code rawJson}, the scalar-only fallback path is + * used instead. + * + *

Always disables the Save button and stamps the status label with the + * snapshot's capture timestamp via {@link SmartPanel#onSnapshotLoaded}. + * + * @param snap the snapshot to display (must not be null) + */ + static public void loadSnapshot(SmartSnapshot snap) { + if (snap == null || smartPanel == null) return; + viewingSnapshot = true; + + String rawJson = snap.getRawJson(); + if (rawJson != null) { + // Full replay — re-parse the original smartctl JSON + try { + Smart smart = Smart.fromJson(rawJson); + smartPanel.populate(smart); + } catch (Exception ex) { + SMART_LOG.log(Level.WARNING, + "loadSnapshot: rawJson parse failed, falling back to scalars", ex); + smartPanel.populateFromSnapshot(snap); + } + } else { + // Legacy snapshot — scalar fields only + smartPanel.populateFromSnapshot(snap); + } + + // Always override toolbar state: read-only view, show capture time + smartPanel.onSnapshotLoaded(snap); + + // Switch focus to the SMART tab + if (mainTabPane != null) { + for (int i = 0; i < mainTabPane.getTabCount(); i++) { + if ("SMART".equals(mainTabPane.getTitleAt(i))) { + mainTabPane.setSelectedIndex(i); + break; + } + } + } + } + + /** + * Saves the most recently fetched SMART data as a {@link SmartSnapshot} + * in the Derby database. Called by the "Save Snapshot" button in + * {@link SmartPanel}. No-op if no data has been loaded yet. + */ + static public void saveCurrentSmartData() { + if (lastSmartData == null || lastSmartDeviceName == null) { + App.msg("No SMART data to save — run a SMART session first."); + return; + } + try { + SmartSnapshot.save(lastSmartData, lastSmartDeviceName); + if (smartPanel != null) smartPanel.onDataSaved(); + if (smartReportsPanel != null) smartReportsPanel.refresh(); + App.msg("SMART snapshot saved for /dev/" + lastSmartDeviceName + "."); + } catch (Exception ex) { + SMART_LOG.log(Level.WARNING, "saveCurrentSmartData: failed", ex); + App.err("Failed to save SMART snapshot: " + ex.getMessage()); + } + } /** * GH-2 need solution for dropping catch diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index 6c5f981..8501c92 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -95,27 +95,50 @@ public MainFrame() { // SMART tab — Linux only (requires smartctl / NVMe kernel support) if (App.isLinux()) { mainTabPane.addTab("SMART", Gui.smartPanel); + Gui.smartReportsPanel = new SmartReportsPanel(); + // SMART Reports lives in the bottom tabbedPane alongside Benchmark Operations + Events + tabbedPane.addTab("SMART Reports", Gui.smartReportsPanel); } - // Fetch SMART data only when the user selects the SMART tab. - // This keeps the pkexec password dialog from appearing at startup. - mainTabPane.addChangeListener(e -> { - int sel = mainTabPane.getSelectedIndex(); - if (sel >= 0 && "SMART".equals(mainTabPane.getTitleAt(sel))) { - Gui.refreshSmartTab(); + // Store reference so SmartReportsPanel can switch to the SMART tab on row selection. + Gui.mainTabPane = mainTabPane; + + // Refresh SMART Reports when its bottom-pane tab is selected. + tabbedPane.addChangeListener(e -> { + int sel = tabbedPane.getSelectedIndex(); + if (sel >= 0 && "SMART Reports".equals(tabbedPane.getTitleAt(sel))) { + if (Gui.smartReportsPanel != null) Gui.smartReportsPanel.refresh(); } }); - // Rebuild the content pane: mainTabPane fills the center; - // the original bottom tabs + progress bar go in a south panel. + // Rebuild the content pane: mainTabPane (top) and the bottom panel (tabbedPane + + // progress bar) are separated by a draggable vertical JSplitPane divider. getContentPane().removeAll(); getContentPane().setLayout(new BorderLayout()); - getContentPane().add(mainTabPane, BorderLayout.CENTER); JPanel southPanel = new JPanel(new BorderLayout()); southPanel.add(tabbedPane, BorderLayout.CENTER); southPanel.add(progressPanel, BorderLayout.SOUTH); - getContentPane().add(southPanel, BorderLayout.SOUTH); + + javax.swing.JSplitPane splitPane = new javax.swing.JSplitPane( + javax.swing.JSplitPane.VERTICAL_SPLIT, mainTabPane, southPanel); + splitPane.setResizeWeight(0.75); // 75% of space goes to the top pane + splitPane.setDividerSize(6); + splitPane.setContinuousLayout(true); + + // Set pixel divider position once the frame is realised (height is known) + addComponentListener(new java.awt.event.ComponentAdapter() { + private boolean initialised = false; + @Override + public void componentResized(java.awt.event.ComponentEvent e) { + if (!initialised) { + initialised = true; + splitPane.setDividerLocation(0.75); + } + } + }); + + getContentPane().add(splitPane, BorderLayout.CENTER); } diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index 03cb070..b8c3c4c 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -199,6 +199,9 @@ public static void logSmart(Smart smart) { // Fields // ------------------------------------------------------------------------- + /** The raw JSON string this object was parsed from. Set by {@link #fromJson}; not a JSON property. */ + private String rawJson; + @JsonProperty("json_format_version") private List jsonFormatVersion; @@ -283,9 +286,14 @@ public Smart() {} */ public static Smart fromJson(String json) throws IOException { ObjectMapper mapper = new ObjectMapper(); - return mapper.readValue(json, Smart.class); + Smart smart = mapper.readValue(json, Smart.class); + smart.rawJson = json; // preserve original for snapshot replay + return smart; } + /** Returns the raw JSON string this object was parsed from, or {@code null} if not set. */ + public String getRawJson() { return rawJson; } + // ------------------------------------------------------------------------- // Top-level getters & setters // ------------------------------------------------------------------------- @@ -661,6 +669,27 @@ public AtaSmartAttributes() {} public List getTable() { return table; } public void setTable(List table) { this.table = table; } + + /** Finds the first attribute with the given {@code id}, or {@code null} if absent. */ + public AtaAttribute findById(int id) { + if (table == null) return null; + for (AtaAttribute a : table) { + if (Integer.valueOf(id).equals(a.getId())) return a; + } + return null; + } + + /** + * Returns the first attribute matching any of the given IDs (checked in + * priority order), or {@code null} if none are present. + */ + public AtaAttribute findByIdAny(int... ids) { + for (int id : ids) { + AtaAttribute a = findById(id); + if (a != null) return a; + } + return null; + } } // ------------------------------------------------------------------------- @@ -871,11 +900,8 @@ public static class NvmeHealthLog { @JsonProperty("critical_comp_time") private Long criticalCompTime; - @JsonProperty("temperature_sensor_1") - private Integer temperatureSensor1; - - @JsonProperty("temperature_sensor_2") - private Integer temperatureSensor2; + @JsonProperty("temperature_sensors") + private List temperatureSensors; public NvmeHealthLog() {} @@ -930,11 +956,20 @@ public NvmeHealthLog() {} public Long getCriticalCompTime() { return criticalCompTime; } public void setCriticalCompTime(Long criticalCompTime) { this.criticalCompTime = criticalCompTime; } - public Integer getTemperatureSensor1() { return temperatureSensor1; } - public void setTemperatureSensor1(Integer temperatureSensor1) { this.temperatureSensor1 = temperatureSensor1; } + public List getTemperatureSensors() { return temperatureSensors; } + public void setTemperatureSensors(List temperatureSensors) { this.temperatureSensors = temperatureSensors; } - public Integer getTemperatureSensor2() { return temperatureSensor2; } - public void setTemperatureSensor2(Integer temperatureSensor2) { this.temperatureSensor2 = temperatureSensor2; } + /** Returns the first temperature sensor value, or {@code null} if absent. */ + public Integer getTemperatureSensor1() { + return (temperatureSensors != null && temperatureSensors.size() >= 1) + ? temperatureSensors.get(0) : null; + } + + /** Returns the second temperature sensor value, or {@code null} if absent. */ + public Integer getTemperatureSensor2() { + return (temperatureSensors != null && temperatureSensors.size() >= 2) + ? temperatureSensors.get(1) : null; + } /** * Returns {@code true} if {@code critical_warning} is non-zero, diff --git a/jdm-core/src/main/java/jdiskmark/SmartPanel.java b/jdm-core/src/main/java/jdiskmark/SmartPanel.java index 52c6fbe..b96787b 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SmartPanel.java @@ -2,13 +2,17 @@ import java.awt.BorderLayout; import java.awt.Color; +import java.awt.Dimension; import java.awt.Font; +import java.awt.Rectangle; import java.util.List; import javax.swing.BorderFactory; +import javax.swing.JButton; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTable; +import javax.swing.Scrollable; import javax.swing.SwingUtilities; import javax.swing.table.DefaultTableModel; import net.miginfocom.swing.MigLayout; @@ -61,10 +65,11 @@ public class SmartPanel extends JPanel { // ------------------------------------------------------------------------- // Health labels // ------------------------------------------------------------------------- - private final JLabel statusValueLabel = value("-"); - private final JLabel tempValueLabel = value("-"); - private final JLabel powerOnValueLabel = value("-"); - private final JLabel powerCyclesValueLabel = value("-"); + private final JLabel statusValueLabel = value("-"); + private final JLabel tempValueLabel = value("-"); + private final JLabel powerOnValueLabel = value("-"); + private final JLabel powerCyclesValueLabel = value("-"); + private final JLabel remainingLifeValueLabel = value("-"); // ------------------------------------------------------------------------- // NVMe Health Log labels @@ -81,6 +86,24 @@ public class SmartPanel extends JPanel { private final JLabel tempSensor2ValueLabel = value("-"); private JPanel nvmeSection; + // ------------------------------------------------------------------------- + // Drive Endurance labels + // ------------------------------------------------------------------------- + private final JLabel wearLevelingValueLabel = value("-"); + private final JLabel badBlockValueLabel = value("-"); + private final JLabel programFailValueLabel = value("-"); + private final JLabel eraseFailValueLabel = value("-"); + private final JLabel eccErrorValueLabel = value("-"); + private final JLabel uncorrErrorValueLabel = value("-"); + private JPanel enduranceSection; + + // ------------------------------------------------------------------------- + // Toolbar controls + // ------------------------------------------------------------------------- + private JButton runButton; + private JButton saveButton; + private JLabel statusLabel; + // ------------------------------------------------------------------------- // ATA Attributes table // ------------------------------------------------------------------------- @@ -112,8 +135,9 @@ public SmartPanel() { ataTable.getColumnModel().getColumn(5).setPreferredWidth(80); ataTable.getColumnModel().getColumn(6).setPreferredWidth(60); - // Inner content panel — sections are added here - JPanel contentPanel = new JPanel(); + // Inner content panel — implements Scrollable so the scroll pane uses + // the panel's natural preferred height instead of stretching to fill the viewport. + ContentPanel contentPanel = new ContentPanel(); contentPanel.setLayout(new MigLayout("insets 12, fillx", "[grow]", "[]8[]8[]8[]8[]")); buildLayout(contentPanel); @@ -125,6 +149,33 @@ public SmartPanel() { scroller.setBorder(null); scroller.getVerticalScrollBar().setUnitIncrement(16); add(scroller, BorderLayout.CENTER); + + // Toolbar with Run and Save buttons (NORTH — above scroll pane) + add(buildToolbar(), BorderLayout.NORTH); + } + + // ------------------------------------------------------------------------- + // Toolbar + // ------------------------------------------------------------------------- + + private JPanel buildToolbar() { + JPanel bar = new JPanel(new MigLayout("insets 8 12 8 12", "[][][grow]", "[]")); + bar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, new Color(80, 80, 80))); + + runButton = new JButton("Run SMART"); + saveButton = new JButton("Save Snapshot"); + saveButton.setEnabled(false); + + statusLabel = new JLabel("Click \u2018Run SMART\u2019 to fetch live data."); + statusLabel.setFont(statusLabel.getFont().deriveFont(Font.ITALIC)); + + runButton.addActionListener(e -> Gui.refreshSmartTab()); + saveButton.addActionListener(e -> Gui.saveCurrentSmartData()); + + bar.add(runButton); + bar.add(saveButton); + bar.add(statusLabel, "growx"); + return bar; } // ------------------------------------------------------------------------- @@ -176,13 +227,15 @@ private void buildLayout(JPanel p) { JPanel healthSection = section("Health"); healthSection.setLayout(new MigLayout("insets 8, wrap 4", COL_SPEC)); healthSection.add(label("SMART Status:")); - healthSection.add(statusValueLabel, "growx"); + healthSection.add(statusValueLabel, "growx"); healthSection.add(label("Temperature:")); - healthSection.add(tempValueLabel, "growx"); + healthSection.add(tempValueLabel, "growx"); healthSection.add(label("Power-On Hours:")); - healthSection.add(powerOnValueLabel, "growx"); + healthSection.add(powerOnValueLabel, "growx"); healthSection.add(label("Power Cycles:")); - healthSection.add(powerCyclesValueLabel, "growx"); + healthSection.add(powerCyclesValueLabel, "growx"); + healthSection.add(label("Remaining Life:")); + healthSection.add(remainingLifeValueLabel, "growx, span 3"); p.add(healthSection, "growx, wrap"); // --- NVMe Health Log section --- @@ -211,6 +264,24 @@ private void buildLayout(JPanel p) { nvmeSection.setVisible(true); p.add(nvmeSection, "growx, wrap"); + // --- Drive Endurance section --- + enduranceSection = section("Drive Endurance"); + enduranceSection.setLayout(new MigLayout("insets 8, wrap 4", COL_SPEC)); + enduranceSection.add(label("Wear Leveling Count:")); + enduranceSection.add(wearLevelingValueLabel, "growx"); + enduranceSection.add(label("Bad Block Count:")); + enduranceSection.add(badBlockValueLabel, "growx"); + enduranceSection.add(label("Program Fail Count:")); + enduranceSection.add(programFailValueLabel, "growx"); + enduranceSection.add(label("Erase Fail Count:")); + enduranceSection.add(eraseFailValueLabel, "growx"); + enduranceSection.add(label("ECC Error Rate:")); + enduranceSection.add(eccErrorValueLabel, "growx"); + enduranceSection.add(label("Uncorrectable Errors:")); + enduranceSection.add(uncorrErrorValueLabel, "growx"); + enduranceSection.setVisible(false); + p.add(enduranceSection, "growx, wrap"); + // --- ATA Attributes section (hidden until ATA data is present) --- ataSection = section("ATA SMART Attributes"); ataSection.setLayout(new MigLayout("insets 8, fill", "[grow]", "[grow]")); @@ -241,6 +312,7 @@ public void populate(Smart data) { fillNvmeDevDetails(data); fillHealth(data); fillNvme(data.getNvmeHealthLog()); + fillEndurance(data); fillAtaAttributes(data.getAtaSmartAttributes()); revalidate(); repaint(); @@ -259,10 +331,14 @@ public void clear() { nvmeNsCountValueLabel, localTimeValueLabel, statusValueLabel, tempValueLabel, powerOnValueLabel, powerCyclesValueLabel, + remainingLifeValueLabel, spareValueLabel, usedPctValueLabel, writtenValueLabel, readValueLabel, mediaErrValueLabel, errLogValueLabel, warnTempValueLabel, critCompValueLabel, - tempSensor1ValueLabel, tempSensor2ValueLabel + tempSensor1ValueLabel, tempSensor2ValueLabel, + wearLevelingValueLabel, badBlockValueLabel, + programFailValueLabel, eraseFailValueLabel, + eccErrorValueLabel, uncorrErrorValueLabel }) { l.setText("-"); l.setForeground(null); @@ -270,7 +346,168 @@ public void clear() { ataModel.setRowCount(0); nvmeDevSection.setVisible(true); // keep visible with dashes nvmeSection.setVisible(true); + enduranceSection.setVisible(false); + ataSection.setVisible(false); + // Reset toolbar state + saveButton.setEnabled(false); + statusLabel.setText("Click \u2018Run SMART\u2019 to fetch live data."); + revalidate(); + repaint(); + }); + } + + /** + * Called after SMART data has been successfully loaded. + * Enables the Save Snapshot button and updates the status label. + * + * @param deviceName the device name that was queried (e.g. {@code nvme0n1}) + */ + public void onDataLoaded(String deviceName) { + SwingUtilities.invokeLater(() -> { + saveButton.setEnabled(true); + statusLabel.setText("Data loaded for /dev/" + deviceName + + ". Click \u2018Save Snapshot\u2019 to store."); + }); + } + + /** + * Called after a snapshot has been successfully saved to the database. + * Disables the Save button so the same data cannot be saved twice — + * the button is only re-enabled by {@link #onDataLoaded(String)} when a + * fresh {@code Run SMART} query completes. + */ + public void onDataSaved() { + SwingUtilities.invokeLater(() -> { + saveButton.setEnabled(false); + statusLabel.setText("Snapshot saved \u2714 \u2014 click \u2018Run SMART\u2019 to fetch new data."); + }); + } + + /** + * Called after the SMART tab has been populated from a stored + * {@link SmartSnapshot} (via the SMART Reports table). + * + *

Disables the Save button (this is a read-only historical view) and + * updates the status label with the snapshot's capture timestamp. + * Safe to call from any thread. + * + * @param snap the snapshot that is currently being displayed + */ + public void onSnapshotLoaded(SmartSnapshot snap) { + SwingUtilities.invokeLater(() -> { + saveButton.setEnabled(false); + String ts = snap.getCapturedAt() != null + ? snap.getCapturedAt().format( + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + : "unknown"; + statusLabel.setText("Viewing stored snapshot from " + ts + + " \u2014 click \u2018Run SMART\u2019 to fetch live data."); + }); + } + + /** + * Populates the panel from a stored {@link SmartSnapshot}. + * Only the fields that are persisted in the DB are shown; sections that + * require a live {@code smartctl} query (NVMe device details, ATA + * attributes, endurance) are hidden. + * + *

Safe to call from any thread. + * + * @param snap the snapshot to display (must not be null) + */ + public void populateFromSnapshot(SmartSnapshot snap) { + SwingUtilities.invokeLater(() -> { + // Reset all value labels to dash + for (JLabel l : new JLabel[]{ + modelValueLabel, serialValueLabel, firmwareValueLabel, + capacityValueLabel, protocolValueLabel, + nvmeVersionValueLabel, nvmeControllerIdValueLabel, + nvmeOuiValueLabel, nvmeVendorValueLabel, + nvmeTotalCapValueLabel, nvmeUnallocCapValueLabel, + nvmeNsCountValueLabel, localTimeValueLabel, + statusValueLabel, tempValueLabel, + powerOnValueLabel, powerCyclesValueLabel, + remainingLifeValueLabel, + spareValueLabel, usedPctValueLabel, writtenValueLabel, + readValueLabel, mediaErrValueLabel, errLogValueLabel, + warnTempValueLabel, critCompValueLabel, + tempSensor1ValueLabel, tempSensor2ValueLabel, + wearLevelingValueLabel, badBlockValueLabel, + programFailValueLabel, eraseFailValueLabel, + eccErrorValueLabel, uncorrErrorValueLabel + }) { l.setText("-"); l.setForeground(null); } + ataModel.setRowCount(0); + + // Drive Info + modelValueLabel.setText(orDash(snap.getModelName())); + serialValueLabel.setText(orDash(snap.getSerialNumber())); + firmwareValueLabel.setText(orDash(snap.getFirmwareVersion())); + protocolValueLabel.setText(orDash(snap.getProtocol())); + if (snap.getCapacityGb() != null) { + capacityValueLabel.setText(snap.getCapacityGb() + " GB"); + } + + // Health + if (snap.getSmartPassed() != null) { + boolean passed = Boolean.TRUE.equals(snap.getSmartPassed()); + statusValueLabel.setText(passed ? "PASSED \u2714" : "FAILED \u2718"); + statusValueLabel.setForeground(passed ? new Color(0x4CAF50) : new Color(0xF44336)); + } + if (snap.getTempC() != null) { + int t = snap.getTempC(); + tempValueLabel.setText(t + " \u00b0C"); + tempValueLabel.setForeground(tempColor(t)); + } + if (snap.getPowerOnHours() != null) { + powerOnValueLabel.setText(snap.getPowerOnHours() + " h"); + } + if (snap.getPowerCycles() != null) { + powerCyclesValueLabel.setText(String.valueOf(snap.getPowerCycles())); + } + if (snap.getPercentageUsed() != null) { + int remaining = Math.max(0, 100 - snap.getPercentageUsed()); + remainingLifeValueLabel.setText(remaining + "%"); + if (remaining > 50) remainingLifeValueLabel.setForeground(new Color(0x4CAF50)); + else if (remaining > 20) remainingLifeValueLabel.setForeground(new Color(0xFF9800)); + else remainingLifeValueLabel.setForeground(new Color(0xF44336)); + } + + // NVMe health log — show only if we have any stored NVMe field + boolean hasNvme = snap.getAvailableSpare() != null + || snap.getPercentageUsed() != null + || snap.getMediaErrors() != null + || snap.getDataWrittenGb() != null; + nvmeSection.setVisible(hasNvme); + if (hasNvme) { + if (snap.getAvailableSpare() != null) + spareValueLabel.setText(snap.getAvailableSpare() + "%"); + if (snap.getPercentageUsed() != null) + usedPctValueLabel.setText(snap.getPercentageUsed() + "%"); + if (snap.getDataWrittenGb() != null) + writtenValueLabel.setText(snap.getDataWrittenGb() + " GB"); + if (snap.getDataReadGb() != null) + readValueLabel.setText(snap.getDataReadGb() + " GB"); + if (snap.getMediaErrors() != null) { + long me = snap.getMediaErrors(); + mediaErrValueLabel.setText(String.valueOf(me)); + mediaErrValueLabel.setForeground(me > 0 ? new Color(0xF44336) : null); + } + } + + // Hide sections not stored in snapshots + nvmeDevSection.setVisible(false); + enduranceSection.setVisible(false); ataSection.setVisible(false); + + // Toolbar: disable Save (this is a read-only view), show snapshot timestamp + saveButton.setEnabled(false); + String ts = snap.getCapturedAt() != null + ? snap.getCapturedAt().format( + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + : "unknown"; + statusLabel.setText("Viewing stored snapshot from " + ts + + " \u2014 click \u2018Run SMART\u2019 to fetch live data."); + revalidate(); repaint(); }); @@ -373,6 +610,16 @@ private void fillHealth(Smart d) { } else if (d.getNvmeHealthLog() != null && d.getNvmeHealthLog().getPowerCycles() != null) { powerCyclesValueLabel.setText(String.valueOf(d.getNvmeHealthLog().getPowerCycles())); } + + // Remaining Life — derived from NVMe percentage_used (100 - used) + if (d.getNvmeHealthLog() != null && d.getNvmeHealthLog().getPercentageUsed() != null) { + int used = d.getNvmeHealthLog().getPercentageUsed(); + int remaining = Math.max(0, 100 - used); + remainingLifeValueLabel.setText(remaining + "%"); + if (remaining > 50) remainingLifeValueLabel.setForeground(new Color(0x4CAF50)); // green + else if (remaining > 20) remainingLifeValueLabel.setForeground(new Color(0xFF9800)); // orange + else remainingLifeValueLabel.setForeground(new Color(0xF44336)); // red + } } private void fillNvme(Smart.NvmeHealthLog nvme) { @@ -408,6 +655,62 @@ private void fillNvme(Smart.NvmeHealthLog nvme) { } } + private void fillEndurance(Smart d) { + Smart.AtaSmartAttributes ata = d.getAtaSmartAttributes(); + Smart.NvmeHealthLog nvme = d.getNvmeHealthLog(); + + boolean hasAta = ata != null && ata.getTable() != null && !ata.getTable().isEmpty(); + boolean hasNvme = nvme != null; + + if (!hasAta && !hasNvme) { enduranceSection.setVisible(false); return; } + enduranceSection.setVisible(true); + + if (hasAta) { + // Wear Leveling Count — attr 177 (0xB1) or 231 (0xE7) + setAtaAttrField(wearLevelingValueLabel, ata.findByIdAny(177, 231)); + // Bad Block / Reallocated Sectors — attr 5 (0x05), 181 (0xB5), + // 197 (0xC5 Current Pending), 198 (0xC6 Uncorrectable) + setAtaAttrField(badBlockValueLabel, ata.findByIdAny(5, 181, 197, 198)); + // Program Fail Count — attr 181 (0xB5) + setAtaAttrField(programFailValueLabel, ata.findById(181)); + // Erase Fail Count — attr 182 (0xB6) + setAtaAttrField(eraseFailValueLabel, ata.findById(182)); + // Raw Read Error Rate — attr 1 (0x01) + setAtaAttrField(eccErrorValueLabel, ata.findById(1)); + // Uncorrectable Errors — attr 187 (0xBB) + setAtaAttrField(uncorrErrorValueLabel, ata.findById(187)); + } else { + // NVMe: map to nearest equivalent health-log fields + // Wear Leveling → percentage_used (endurance consumed) + if (nvme.getPercentageUsed() != null) { + wearLevelingValueLabel.setText(nvme.getPercentageUsed() + "% used"); + wearLevelingValueLabel.setForeground( + nvme.getPercentageUsed() < 80 ? new Color(0x4CAF50) : new Color(0xF44336)); + } + // Bad Block Count → media_errors + if (nvme.getMediaErrors() != null) { + long me = nvme.getMediaErrors(); + badBlockValueLabel.setText(String.valueOf(me)); + badBlockValueLabel.setForeground(me > 0 ? new Color(0xF44336) : null); + } + // Remaining fields not present in standard NVMe health log + programFailValueLabel.setText("N/A"); + eraseFailValueLabel.setText("N/A"); + eccErrorValueLabel.setText("N/A"); + uncorrErrorValueLabel.setText("N/A"); + } + } + + /** Populates {@code lbl} from an ATA attribute's raw string, or "N/A" if absent. */ + private void setAtaAttrField(JLabel lbl, Smart.AtaAttribute attr) { + if (attr == null) { lbl.setText("N/A"); return; } + String raw = (attr.getRaw() != null && attr.getRaw().getString() != null) + ? attr.getRaw().getString() + : (attr.getValue() != null ? String.valueOf(attr.getValue()) : "-"); + lbl.setText(raw); + if (attr.isFailing()) lbl.setForeground(new Color(0xF44336)); + } + private void fillAtaAttributes(Smart.AtaSmartAttributes ata) { ataModel.setRowCount(0); if (ata == null || ata.getTable() == null || ata.getTable().isEmpty()) { @@ -477,4 +780,37 @@ private static JPanel section(String title) { BorderFactory.createEtchedBorder(), title)); return p; } + + // ------------------------------------------------------------------------- + // Scrollable content panel + // ------------------------------------------------------------------------- + + /** + * A JPanel that implements {@link Scrollable} to prevent the enclosing + * {@link JScrollPane} from stretching it to fill the viewport height. + * The panel will only be as tall as its content, eliminating blank space + * below the last section. + */ + private static class ContentPanel extends JPanel implements Scrollable { + ContentPanel() { super(); } + + @Override + public Dimension getPreferredScrollableViewportSize() { return getPreferredSize(); } + + @Override + public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) { return 16; } + + @Override + public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) { + return visibleRect.height; + } + + /** Fill the viewport width so content stretches horizontally. */ + @Override + public boolean getScrollableTracksViewportWidth() { return true; } + + /** Do NOT fill viewport height — use natural content height to avoid blank space. */ + @Override + public boolean getScrollableTracksViewportHeight() { return false; } + } } diff --git a/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java b/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java new file mode 100644 index 0000000..e34cbe5 --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java @@ -0,0 +1,238 @@ +package jdiskmark; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Font; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.SwingUtilities; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.DefaultTableModel; +import net.miginfocom.swing.MigLayout; + +/** + * Displays all stored {@link SmartSnapshot} records in a sortable table. + * + *

Shown in the "SMART Reports" tab (Linux only). Rows are loaded from the + * embedded Derby database. Use the Refresh button or navigate away and back + * to reload after saving new snapshots. + * + * @author jasmine + */ +public class SmartReportsPanel extends JPanel { + + private static final Logger LOGGER = Logger.getLogger(SmartReportsPanel.class.getName()); + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private final DefaultTableModel model; + private final JTable table; + private final JLabel countLabel; + /** Parallel list to the table rows — used by the selection listener. */ + private java.util.List lastLoaded = new java.util.ArrayList<>(); + + public SmartReportsPanel() { + super(new BorderLayout()); + + // ── Toolbar ────────────────────────────────────────────────────────── + JPanel toolbar = new JPanel(new MigLayout("insets 8 12 8 12", "[][][][][grow]", "[]")); + toolbar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, + new Color(80, 80, 80))); + + JButton refreshBtn = new JButton("Refresh"); + JButton deleteSelectedBtn = new JButton("Delete Selected"); + JButton deleteAllBtn = new JButton("Delete All"); + countLabel = new JLabel("No snapshots stored."); + countLabel.setFont(countLabel.getFont().deriveFont(Font.ITALIC)); + + refreshBtn.addActionListener(e -> refresh()); + // delete listeners attached below, after table/model are initialized + + toolbar.add(refreshBtn); + toolbar.add(deleteSelectedBtn); + toolbar.add(deleteAllBtn); + toolbar.add(countLabel, "growx"); + add(toolbar, BorderLayout.NORTH); + + // ── Table ───────────────────────────────────────────────────────────── + model = new DefaultTableModel( + new String[]{ + "Captured At", "Device", "Model", + "Health", "Temp (°C)", "Life Left", + "Power-On Hrs", "Media Errors" + }, 0 + ) { + @Override public boolean isCellEditable(int r, int c) { return false; } + }; + + table = new JTable(model); + table.setFillsViewportHeight(true); + table.setRowHeight(22); + table.getColumnModel().getColumn(0).setPreferredWidth(130); + table.getColumnModel().getColumn(1).setPreferredWidth(90); + table.getColumnModel().getColumn(2).setPreferredWidth(220); + table.getColumnModel().getColumn(3).setPreferredWidth(90); + table.getColumnModel().getColumn(4).setPreferredWidth(70); + table.getColumnModel().getColumn(5).setPreferredWidth(70); + table.getColumnModel().getColumn(6).setPreferredWidth(100); + table.getColumnModel().getColumn(7).setPreferredWidth(90); + + // Color the Health column (index 3) + table.getColumnModel().getColumn(3).setCellRenderer( + new DefaultTableCellRenderer() { + @Override + public java.awt.Component getTableCellRendererComponent( + JTable t, Object value, boolean selected, boolean focus, + int row, int col) { + super.getTableCellRendererComponent(t, value, selected, focus, row, col); + if (!selected) { + String v = value != null ? value.toString() : ""; + if (v.contains("PASSED")) setForeground(new Color(0x4CAF50)); + else if (v.contains("FAILED")) setForeground(new Color(0xF44336)); + else setForeground(null); + } + return this; + } + }); + + // Color the Media Errors column (index 7) + table.getColumnModel().getColumn(7).setCellRenderer( + new DefaultTableCellRenderer() { + @Override + public java.awt.Component getTableCellRendererComponent( + JTable t, Object value, boolean selected, boolean focus, + int row, int col) { + super.getTableCellRendererComponent(t, value, selected, focus, row, col); + if (!selected && value != null && !"-".equals(value.toString())) { + try { + long v = Long.parseLong(value.toString()); + setForeground(v > 0 ? new Color(0xF44336) : null); + } catch (NumberFormatException ignore) {} + } + return this; + } + }); + + JScrollPane scroller = new JScrollPane(table); + scroller.setBorder(null); + add(scroller, BorderLayout.CENTER); + + // Row selection — load the clicked snapshot into the SMART tab for full replay + table.getSelectionModel().addListSelectionListener(e -> { + if (e.getValueIsAdjusting()) return; + int row = table.getSelectedRow(); + if (row < 0 || row >= lastLoaded.size()) return; + SmartSnapshot snap = lastLoaded.get(row); + Gui.loadSnapshot(snap); + }); + + // Delete listeners — attached here so table/model are guaranteed initialized + deleteSelectedBtn.addActionListener(e -> { + int row = table.getSelectedRow(); + if (row < 0 || row >= lastLoaded.size()) { + javax.swing.JOptionPane.showMessageDialog( + Gui.mainFrame, + "Please select a row first.", + "No Selection", + javax.swing.JOptionPane.WARNING_MESSAGE); + return; + } + SmartSnapshot snap = lastLoaded.get(row); + String label = snap.getCapturedAt() != null + ? snap.getCapturedAt().format(FMT) : "unknown"; + int confirm = javax.swing.JOptionPane.showConfirmDialog( + Gui.mainFrame, + "Delete snapshot from " + label + "?\nThis cannot be undone.", + "Confirm Delete", + javax.swing.JOptionPane.YES_NO_OPTION, + javax.swing.JOptionPane.WARNING_MESSAGE); + if (confirm == javax.swing.JOptionPane.YES_OPTION) { + SmartSnapshot.delete(snap.getId()); + refresh(); + } + }); + + deleteAllBtn.addActionListener(e -> { + int total = model.getRowCount(); + if (total == 0) { + javax.swing.JOptionPane.showMessageDialog( + Gui.mainFrame, + "No snapshots to delete.", + "Nothing to Delete", + javax.swing.JOptionPane.INFORMATION_MESSAGE); + return; + } + int confirm = javax.swing.JOptionPane.showConfirmDialog( + Gui.mainFrame, + "Delete all " + total + " snapshot(s)?\nThis cannot be undone.", + "Confirm Delete All", + javax.swing.JOptionPane.YES_NO_OPTION, + javax.swing.JOptionPane.WARNING_MESSAGE); + if (confirm == javax.swing.JOptionPane.YES_OPTION) { + SmartSnapshot.deleteAll(); + refresh(); + } + }); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Reloads all snapshots from the database and refreshes the table. + * Safe to call from any thread. + */ + public void refresh() { + SwingUtilities.invokeLater(() -> { + model.setRowCount(0); + lastLoaded = new java.util.ArrayList<>(); + try { + List snaps = SmartSnapshot.findAll(); + lastLoaded = snaps; + for (SmartSnapshot s : snaps) { + String capturedAt = s.getCapturedAt() != null + ? s.getCapturedAt().format(FMT) : "-"; + String health = s.getSmartPassed() == null ? "-" + : Boolean.TRUE.equals(s.getSmartPassed()) + ? "\u2714 PASSED" : "\u2718 FAILED"; + String temp = s.getTempC() != null ? s.getTempC() + " \u00b0C" : "-"; + String lifeLeft = s.getPercentageUsed() != null + ? Math.max(0, 100 - s.getPercentageUsed()) + "%" : "-"; + String poh = s.getPowerOnHours() != null + ? String.valueOf(s.getPowerOnHours()) : "-"; + String mediaErr = s.getMediaErrors() != null + ? String.valueOf(s.getMediaErrors()) : "-"; + model.addRow(new Object[]{ + capturedAt, + orDash(s.getDeviceName()), + orDash(s.getModelName()), + health, temp, lifeLeft, poh, mediaErr + }); + } + int count = model.getRowCount(); + countLabel.setText(count == 0 + ? "No snapshots stored." + : count + " snapshot" + (count == 1 ? "" : "s") + " stored."); + } catch (Exception ex) { + LOGGER.log(Level.WARNING, "Failed to load SmartSnapshots", ex); + countLabel.setText("Error loading snapshots — see log."); + } + }); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static String orDash(String s) { + return (s != null && !s.isBlank()) ? s : "-"; + } +} diff --git a/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java b/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java new file mode 100644 index 0000000..d9019df --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java @@ -0,0 +1,295 @@ +package jdiskmark; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.NamedQueries; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A point-in-time snapshot of S.M.A.R.T. health data for one drive. + * + *

Persisted independently of benchmark data in the same Derby database. + * Rows are created explicitly by the user via the "Save Snapshot" button in + * the SMART tab — there is no automatic capture. + * + *

Use {@link #save(Smart, String)} to persist and {@link #findAll()} to + * retrieve all stored snapshots ordered by capture time descending. + * + * @author jasmine + */ +@Entity +@Table(name = "SmartSnapshot") +@NamedQueries({ + @NamedQuery( + name = "SmartSnapshot.findAll", + query = "SELECT s FROM SmartSnapshot s ORDER BY s.capturedAt DESC" + ), + @NamedQuery( + name = "SmartSnapshot.deleteAll", + query = "DELETE FROM SmartSnapshot s" + ) +}) +public class SmartSnapshot implements Serializable { + + private static final Logger LOGGER = Logger.getLogger(SmartSnapshot.class.getName()); + + // ------------------------------------------------------------------------- + // Primary key + // ------------------------------------------------------------------------- + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // ------------------------------------------------------------------------- + // Capture metadata + // ------------------------------------------------------------------------- + + @Convert(converter = LocalDateTimeAttributeConverter.class) + @Column(name = "capturedAt", columnDefinition = "TIMESTAMP") + private LocalDateTime capturedAt; + + @Column + private String deviceName; + + // ------------------------------------------------------------------------- + // Drive identity + // ------------------------------------------------------------------------- + + @Column + private String modelName; + + @Column + private String serialNumber; + + @Column + private String firmwareVersion; + + @Column + private Double capacityGb; + + @Column + private String protocol; + + // ------------------------------------------------------------------------- + // Health fields + // ------------------------------------------------------------------------- + + @Column + private Boolean smartPassed; + + @Column + private Integer tempC; + + @Column + private Long powerOnHours; + + @Column + private Long powerCycles; + + // ------------------------------------------------------------------------- + // NVMe health log fields (null for ATA drives) + // ------------------------------------------------------------------------- + + @Column + private Integer percentageUsed; + + @Column + private Integer availableSpare; + + @Column + private Long mediaErrors; + + @Column + private Double dataWrittenGb; + + @Column + private Double dataReadGb; + + // ------------------------------------------------------------------------- + // Full JSON (for lossless replay) + // ------------------------------------------------------------------------- + + /** + * The raw {@code smartctl --json -a} output this snapshot was captured from. + * {@code null} for snapshots saved before this field was added. + * When present, the SMART tab can perform a full replay via + * {@link Smart#fromJson(String)} instead of the scalar-only fallback. + */ + @Lob + @Column(name = "rawJson", columnDefinition = "CLOB") + private String rawJson; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + public SmartSnapshot() {} + + // ------------------------------------------------------------------------- + // Factory / save + // ------------------------------------------------------------------------- + + /** + * Extracts the key health fields from a live {@link Smart} object and + * persists a new {@link SmartSnapshot} row in the Derby database. + * + * @param smart the parsed SMART data (must not be null) + * @param deviceName the bare device name, e.g. {@code nvme0n1} + * @return the persisted snapshot, or {@code null} on error + */ + public static SmartSnapshot save(Smart smart, String deviceName) { + SmartSnapshot snap = new SmartSnapshot(); + snap.capturedAt = LocalDateTime.now(); + snap.deviceName = deviceName; + snap.modelName = smart.getModelName(); + snap.serialNumber = smart.getSerialNumber(); + snap.firmwareVersion = smart.getFirmwareVersion(); + + if (smart.getUserCapacity() != null) { + snap.capacityGb = smart.getUserCapacity().getCapacityGb(); + } + if (smart.getDevice() != null) { + snap.protocol = smart.getDevice().getProtocol(); + } + if (smart.getSmartStatus() != null) { + snap.smartPassed = smart.getSmartStatus().isPassed(); + } + + // Temperature — prefer top-level block, fall back to NVMe health log + if (smart.getTemperature() != null && smart.getTemperature().getCurrent() != null) { + snap.tempC = smart.getTemperature().getCurrent(); + } else if (smart.getNvmeHealthLog() != null + && smart.getNvmeHealthLog().getTemperature() != null) { + snap.tempC = smart.getNvmeHealthLog().getTemperature(); + } + + // Power-on hours + if (smart.getPowerOnTime() != null && smart.getPowerOnTime().getHours() != null) { + snap.powerOnHours = smart.getPowerOnTime().getHours(); + } else if (smart.getNvmeHealthLog() != null + && smart.getNvmeHealthLog().getPowerOnHours() != null) { + snap.powerOnHours = smart.getNvmeHealthLog().getPowerOnHours(); + } + + // Power cycles + if (smart.getPowerCycleCount() != null) { + snap.powerCycles = smart.getPowerCycleCount().longValue(); + } else if (smart.getNvmeHealthLog() != null + && smart.getNvmeHealthLog().getPowerCycles() != null) { + snap.powerCycles = smart.getNvmeHealthLog().getPowerCycles(); + } + + // NVMe health log + Smart.NvmeHealthLog nvme = smart.getNvmeHealthLog(); + if (nvme != null) { + snap.percentageUsed = nvme.getPercentageUsed(); + snap.availableSpare = nvme.getAvailableSpare(); + snap.mediaErrors = nvme.getMediaErrors(); + snap.dataWrittenGb = nvme.getDataWrittenGb(); + snap.dataReadGb = nvme.getDataReadGb(); + } + + // Full JSON for lossless replay + snap.rawJson = smart.getRawJson(); + + try { + EntityManager em = EM.getEntityManager(); + em.getTransaction().begin(); + em.persist(snap); + em.getTransaction().commit(); + LOGGER.info("SmartSnapshot saved: device=" + deviceName + + " model=" + snap.modelName + " passed=" + snap.smartPassed); + } catch (Exception ex) { + LOGGER.log(Level.SEVERE, "Failed to persist SmartSnapshot", ex); + return null; + } + return snap; + } + + /** + * Returns all stored snapshots ordered by capture time descending + * (newest first). + */ + public static List findAll() { + return EM.getEntityManager() + .createNamedQuery("SmartSnapshot.findAll", SmartSnapshot.class) + .getResultList(); + } + + /** + * Deletes the snapshot with the given primary key. + * + * @param id the {@link #id} of the record to remove + * @return {@code true} if the record was found and deleted + */ + public static boolean delete(Long id) { + try { + EntityManager em = EM.getEntityManager(); + SmartSnapshot snap = em.find(SmartSnapshot.class, id); + if (snap == null) return false; + em.getTransaction().begin(); + em.remove(snap); + em.getTransaction().commit(); + LOGGER.info("SmartSnapshot deleted: id=" + id); + return true; + } catch (Exception ex) { + LOGGER.log(Level.SEVERE, "Failed to delete SmartSnapshot id=" + id, ex); + return false; + } + } + + /** + * Deletes all stored snapshots in a single bulk operation. + * + * @return the number of rows deleted + */ + public static int deleteAll() { + try { + EntityManager em = EM.getEntityManager(); + em.getTransaction().begin(); + int count = em.createNamedQuery("SmartSnapshot.deleteAll").executeUpdate(); + em.getTransaction().commit(); + LOGGER.info("SmartSnapshot.deleteAll: removed " + count + " row(s)."); + return count; + } catch (Exception ex) { + LOGGER.log(Level.SEVERE, "Failed to delete all SmartSnapshots", ex); + return 0; + } + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + public Long getId() { return id; } + public LocalDateTime getCapturedAt() { return capturedAt; } + public String getDeviceName() { return deviceName; } + public String getModelName() { return modelName; } + public String getSerialNumber() { return serialNumber; } + public String getFirmwareVersion() { return firmwareVersion; } + public Double getCapacityGb() { return capacityGb; } + public String getProtocol() { return protocol; } + public Boolean getSmartPassed() { return smartPassed; } + public Integer getTempC() { return tempC; } + public Long getPowerOnHours() { return powerOnHours; } + public Long getPowerCycles() { return powerCycles; } + public Integer getPercentageUsed() { return percentageUsed; } + public Integer getAvailableSpare() { return availableSpare; } + public Long getMediaErrors() { return mediaErrors; } + public Double getDataWrittenGb() { return dataWrittenGb; } + public Double getDataReadGb() { return dataReadGb; } + public String getRawJson() { return rawJson; } +} From cacadb3df2bd8ae5ec61ad3420d87d4dee5a83fe Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 28 Jun 2026 13:27:31 -0700 Subject: [PATCH 09/22] #61 Bundle smartctl 7.5 and polkit policy in fat DEB installer - Add prepare-smartctl.yml workflow to JDiskMark/jdm-deps to build smartctl 7.5 from source (ubuntu:22.04, glibc 2.35 target) and publish as a GitHub Release asset - linux-deb.yml: fetch smartctl from jdm-deps before Maven build; extend smoke test to verify binary is present and runs - jdm-deb/pom.xml: inject smartctl/ dir and polkit policy file into the DEB during the existing repack step (dpkg-deb -R/-b) - template.postinst: install net.jdiskmark.smartctl.policy to /usr/share/polkit-1/actions/ for branded pkexec prompt - Smart.java: resolveSmartctlPath() prefers bundled binary at \$APPDIR/../smartctl/smartctl with fallback to /usr/sbin/smartctl --app-content is silently ignored by jpackage when building DEB packages directly. Use the existing repack bash step (dpkg-deb -R/-b) to inject smartctl/ and the polkit policy file into the unpacked image before repacking. Also make the postinst polkit install non-fatal so headless CI runners without polkit installed don't fail the package hook. commit 7f6b4ef6102fca7b22ef9588041f8397ff4e3703 Author: James Mark Chan Date: Sat Jun 27 18:52:01 2026 -0700 --- .github/workflows/linux-deb.yml | 18 ++++++++ jdm-core/src/main/java/jdiskmark/Smart.java | 41 ++++++++++++++++++- jdm-dist/jdm-deb/.gitignore | 4 ++ jdm-dist/jdm-deb/pom.xml | 20 +++++++++ .../net.jdiskmark.smartctl.policy | 27 ++++++++++++ .../main/jpackage-resources/template.postinst | 7 ++++ 6 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 jdm-dist/jdm-deb/.gitignore create mode 100644 jdm-dist/jdm-deb/src/main/jpackage-resources/net.jdiskmark.smartctl.policy diff --git a/.github/workflows/linux-deb.yml b/.github/workflows/linux-deb.yml index d574297..8201b1e 100644 --- a/.github/workflows/linux-deb.yml +++ b/.github/workflows/linux-deb.yml @@ -31,6 +31,21 @@ jobs: distribution: 'temurin' cache: maven + - name: Fetch bundled smartctl from jdm-deps + # Downloads the pre-packaged smartctl 7.5 binary from the public + # JDiskMark/jdm-deps release asset. The staging directory is picked + # up by jpackage --app-content (configured in jdm-deb/pom.xml) and + # lands at /opt/jdiskmark/smartctl/smartctl in the installed image. + run: | + curl -fsSL -o smartctl.tar.gz \ + https://github.com/JDiskMark/jdm-deps/releases/download/tools%2Fsmartctl-7.5/smartctl-7.5-linux-x86_64.tar.gz + mkdir -p jdm-dist/jdm-deb/src/main/app-content + tar -xzf smartctl.tar.gz -C jdm-dist/jdm-deb/src/main/app-content + # Sanity check: binary must exist and be executable + test -x jdm-dist/jdm-deb/src/main/app-content/smartctl/smartctl + echo "smartctl staging OK:" + jdm-dist/jdm-deb/src/main/app-content/smartctl/smartctl --version || true + - name: Build Fat Debian Package # -pl targets jdm-core and jdm-deb. -am also builds upstream dependencies. # Explicitly activating the linux-deb profile to ensure jpackage runs. @@ -52,6 +67,9 @@ jobs: run: | sudo dpkg -i jdm-dist/jdm-deb/target/*.deb || true /opt/jdiskmark/bin/jdiskmark --help + # Verify bundled smartctl was installed correctly + test -x /opt/jdiskmark/smartctl/smartctl + /opt/jdiskmark/smartctl/smartctl --version # --- FAT DEB COMPAT CHECK — verify the artifact installs on ubuntu-24.04 --- # Downloads the artifact built above and smoke tests it on 24.04. diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index b8c3c4c..c4b68d3 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -9,6 +9,8 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -65,6 +67,43 @@ public class Smart { private static final Logger LOGGER = Logger.getLogger(Smart.class.getName()); + /** + * Resolves the path to the {@code smartctl} binary, preferring a bundled + * copy shipped with the JDiskMark fat installer over the system installation. + * + *

Resolution order: + *

    + *
  1. {@code $APPDIR/../smartctl/smartctl} — jpackage sets {@code APPDIR} + * at runtime, pointing to the {@code app/} subdirectory of the + * install root (e.g. {@code /opt/jdiskmark/app}). The bundled binary + * lands one level up at {@code /opt/jdiskmark/smartctl/smartctl}.
  2. + *
  3. {@code /opt/jdiskmark/smartctl/smartctl} — well-known absolute path + * for the fat DEB install, used when APPDIR is not set.
  4. + *
  5. {@code /usr/sbin/smartctl} — system fallback for dev environments + * and slim-DEB users who have smartmontools installed system-wide.
  6. + *
+ */ + static String resolveSmartctlPath() { + // 1. Bundled copy: jpackage sets APPDIR → …/opt/jdiskmark/app + String appDir = System.getenv("APPDIR"); + if (appDir != null) { + Path bundled = Path.of(appDir).getParent().resolve("smartctl/smartctl"); + if (Files.isExecutable(bundled)) { + LOGGER.info("Using bundled smartctl (APPDIR): " + bundled); + return bundled.toString(); + } + } + // 2. Well-known absolute path (fat DEB install without APPDIR in env) + Path installed = Path.of("/opt/jdiskmark/smartctl/smartctl"); + if (Files.isExecutable(installed)) { + LOGGER.info("Using installed smartctl: " + installed); + return installed.toString(); + } + // 3. System fallback — dev machines, slim DEB with apt smartmontools + LOGGER.info("Using system smartctl: /usr/sbin/smartctl"); + return "/usr/sbin/smartctl"; + } + /** * Launches a single {@code pkexec bash} process and wires up the * shared {@link #shellWriter} / {@link #shellReader}. The user is @@ -138,7 +177,7 @@ public static Smart getSmart(String deviceName) { synchronized (pLock) { // Write the smartctl command followed by an echo of the sentinel // so we know exactly where the JSON output ends. - shellWriter.write("/usr/sbin/smartctl --json -a /dev/" + deviceName + "\n"); + shellWriter.write(resolveSmartctlPath() + " --json -a /dev/" + deviceName + "\n"); shellWriter.write("echo '" + sentinel + "'\n"); shellWriter.flush(); diff --git a/jdm-dist/jdm-deb/.gitignore b/jdm-dist/jdm-deb/.gitignore new file mode 100644 index 0000000..67274cc --- /dev/null +++ b/jdm-dist/jdm-deb/.gitignore @@ -0,0 +1,4 @@ +# CI-populated content — not tracked in source control. +# The linux-deb.yml workflow downloads and extracts the smartctl binary here +# before the Maven build runs jpackage --app-content against this directory. +src/main/app-content/ diff --git a/jdm-dist/jdm-deb/pom.xml b/jdm-dist/jdm-deb/pom.xml index 3809a78..3c746cc 100644 --- a/jdm-dist/jdm-deb/pom.xml +++ b/jdm-dist/jdm-deb/pom.xml @@ -93,6 +93,26 @@ if [ -n "$DESKTOP" ]; then echo "[patch] Result ($(basename $DESKTOP)):" cat "$DESKTOP" fi + +# Inject bundled smartctl runtime (staged by linux-deb.yml before Maven runs). +# --app-content is not reliable for the DEB format so we inject here instead. +APP_CONTENT="${project.basedir}/src/main/app-content" +if [ -d "$APP_CONTENT/smartctl" ]; then + cp -r "$APP_CONTENT/smartctl" "$WORK/pkg/opt/jdiskmark/smartctl" + chmod 755 "$WORK/pkg/opt/jdiskmark/smartctl/smartctl" + echo "[patch] Injected smartctl: $($WORK/pkg/opt/jdiskmark/smartctl/smartctl --version 2>&1 | head -1)" +else + echo "[patch] WARNING: app-content/smartctl not found — skipping (slim build or local dev)" +fi + +# Copy the polkit policy file so postinst can install it from the package root. +POLICY="${project.basedir}/src/main/jpackage-resources/net.jdiskmark.smartctl.policy" +if [ -f "$POLICY" ]; then + cp "$POLICY" "$WORK/pkg/opt/jdiskmark/net.jdiskmark.smartctl.policy" + chmod 644 "$WORK/pkg/opt/jdiskmark/net.jdiskmark.smartctl.policy" + echo "[patch] Included polkit policy file" +fi + dpkg-deb -b "$WORK/pkg" "$DEB" echo "[patch] Done." ]]> diff --git a/jdm-dist/jdm-deb/src/main/jpackage-resources/net.jdiskmark.smartctl.policy b/jdm-dist/jdm-deb/src/main/jpackage-resources/net.jdiskmark.smartctl.policy new file mode 100644 index 0000000..e523063 --- /dev/null +++ b/jdm-dist/jdm-deb/src/main/jpackage-resources/net.jdiskmark.smartctl.policy @@ -0,0 +1,27 @@ + + + + + JDiskMark + https://jdiskmark.net + + + Read SMART drive health data + JDiskMark needs administrator access to read SMART data from your drive. + jdiskmark + + + auth_admin + + auth_admin + + auth_admin_keep + + + /opt/jdiskmark/smartctl/smartctl + true + + + diff --git a/jdm-dist/jdm-deb/src/main/jpackage-resources/template.postinst b/jdm-dist/jdm-deb/src/main/jpackage-resources/template.postinst index c5e2e40..d9ada78 100644 --- a/jdm-dist/jdm-deb/src/main/jpackage-resources/template.postinst +++ b/jdm-dist/jdm-deb/src/main/jpackage-resources/template.postinst @@ -24,9 +24,16 @@ case "$1" in configure) DESKTOP_COMMANDS_INSTALL LAUNCHER_AS_SERVICE_COMMANDS_INSTALL + # Install the polkit action file so pkexec shows a branded JDiskMark + # prompt when the app requests SMART data access, rather than the + # generic "unknown application wants to run as root" dialog. + install -D -m 644 \ + /opt/jdiskmark/net.jdiskmark.smartctl.policy \ + /usr/share/polkit-1/actions/net.jdiskmark.smartctl.policy || true ;; abort-upgrade|abort-remove|abort-deconfigure) + rm -f /usr/share/polkit-1/actions/net.jdiskmark.smartctl.policy ;; *) From 669985ea9c0ec9c030f972fa1c4a72c3bc685ec8 Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 28 Jun 2026 14:31:06 -0700 Subject: [PATCH 10/22] #61 update readme --- README.md | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a4a297b..8998955 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ sudo pkgutil --forget net.jdiskmark.JDiskMark ### Flatpak Installer (.flatpak) The flatpak installer is a universal linux package that can be used on many distributions. -Some gaming-oriented distros such as Bazzite or SteamOS have Flatpak and Flathub +Some gaming-oriented distros such as Bazzite or SteamOS have Flatpak and Flathub pre-configured. #### 1. Add Flathub (if not already configured) @@ -147,7 +147,7 @@ Java 25 to be installed separately. ## Launching as normal process -Note: Running without sudo or a windows administrator will require manually +Note: Running without sudo or a windows administrator will require manually clearing the disk write cache before performing read benchmarks. 1. Open a terminal or shell in the extracted directory. @@ -166,7 +166,7 @@ clearing the disk write cache before performing read benchmarks. ## Launching gui with elevated privileges -Note: Take advantage of automatic clearing of the disk cache for write read +Note: Take advantage of automatic clearing of the disk cache for write read benchmarks start with sudo or an administrator windows shell. - Linux: `sudo java -jar jdiskmark.jar` @@ -295,6 +295,40 @@ JDiskMark is developed with [NetBeans 25](https://netbeans.apache.org/front/main | Flatpak (Linux only) | `mvn clean install -pl jdm-core,jdm-dist/jdm-flatpak -am -Plinux-flatpak` | | macOS PKG (macOS only) | `mvn clean install -pl jdm-core,jdm-dist/jdm-pkg -am -Pmacos-pkg` | +### SMART Feature Development (Linux) + +The SMART tab uses `smartctl` to query drive health data via `pkexec` privilege +escalation. Two setup tiers are available depending on what you are testing: + +**Tier 1 — Day-to-day development** (recommended for most contributors) + +Install `smartmontools` from your system package manager: + +```sh +sudo apt install smartmontools # Ubuntu / Debian +sudo dnf install smartmontools # Fedora / RHEL +``` + +The app will find `/usr/sbin/smartctl` automatically as a fallback. +The system version may be older than the bundled binary (7.2 on Ubuntu 22.04, +7.4 on Ubuntu 24.04) but is sufficient for testing all SMART code paths. + +**Tier 2 — Integration / packaging testing** + +Install the latest fat DEB artifact from CI +(`jdiskmark__amd64.deb`): + +```sh +sudo dpkg -i jdiskmark__amd64.deb +``` + +This places the bundled `smartctl 7.5` binary at +`/opt/jdiskmark/smartctl/smartctl`. The app's path resolver +(`Smart.resolveSmartctlPath()`) finds it at that well-known path and uses it +automatically — even when running from the IDE. No `PATH` changes are needed. +This tier lets you test the exact runtime users will have after installing the +fat DEB. + ### Pipeline triggered pre-release ## GitHub Actions From d7be92bab8052f3e7358dbb42fa15bc409f1a1b3 Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 28 Jun 2026 14:55:37 -0700 Subject: [PATCH 11/22] #61 remove enable guard of Run SMART button --- jdm-core/src/main/java/jdiskmark/Gui.java | 41 +++++++++++-------- .../src/main/java/jdiskmark/MainFrame.form | 2 +- .../src/main/java/jdiskmark/MainFrame.java | 2 +- .../src/main/java/jdiskmark/SmartPanel.java | 2 +- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index a3e7127..632a4b1 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -17,10 +17,12 @@ import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.File; +import java.io.IOException; import java.nio.file.Path; import java.text.NumberFormat; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.JOptionPane; @@ -99,12 +101,12 @@ public String toString() { public static SmartReportsPanel smartReportsPanel = null; public static javax.swing.JTabbedPane mainTabPane = null; public static JProgressBar progressBar = null; - // last SMART data captured via refreshSmartTab() — used by Save Snapshot button + // last SMART data captured via runSmart() — used by Save Snapshot button public static Smart lastSmartData = null; public static String lastSmartDeviceName = null; /** * {@code true} while the SMART tab is displaying a stored {@link SmartSnapshot} - * rather than freshly fetched live data. Cleared when {@link #refreshSmartTab()} + * rather than freshly fetched live data. Cleared when {@link #runSmart()} * starts a new live query; set by {@link #loadSnapshot(SmartSnapshot)}. */ public static boolean viewingSnapshot = false; @@ -670,24 +672,29 @@ static public void updateDiskInfo() { if (drivesPanel != null) { drivesPanel.refresh(); } - // SMART data is fetched lazily via refreshSmartTab(), which is called - // by the tab-change listener in MainFrame only when the user selects - // the SMART tab. This prevents the pkexec password dialog from - // appearing before the application window is visible. + // SMART data is fetched lazily via runSmart(), which is called + // by the "Run SMART" button in SmartPanel and optionally after each + // benchmark when "Run SMART with Benchmark" is enabled. } /** * Fetches fresh SMART data for the current drive in a background thread - * and populates the SMART panel when done. Triggers the pkexec password + * and populates the SMART panel when done. Triggers the pkexec password * prompt on the very first call (or after the privileged shell dies). * *

Safe to call from the EDT; the privileged I/O runs off-thread. - * No-op if SMART is disabled, the OS is not Linux, or the panel is null. - * Called by the "Run SMART" button in {@link SmartPanel}. + * Called by the "Run SMART" button in {@link SmartPanel} and by + * {@link jdiskmark.BenchmarkRunner} when "Run SMART with Benchmark" is enabled. */ - static public void refreshSmartTab() { - if (!Smart.smartEnable || !App.isLinux() || smartPanel == null - || App.locationDir == null) { + static public void runSmart() { + + if (!App.isLinux()) { + App.msg("SMART is only available in linux"); + return; + } + + if (smartPanel == null || App.locationDir == null) { + App.msg("smartPanel and locationDir must first be initialized"); return; } // A live query is starting — we are no longer viewing a stored snapshot. @@ -705,7 +712,7 @@ protected Smart doInBackground() { List devices = UtilOs.getDeviceNamesFromPartitionLinux(partition); if (devices == null || devices.isEmpty()) { - SMART_LOG.warning("refreshSmartTab: no device for " + locDir); + SMART_LOG.log(Level.WARNING, "runSmart: no device for {0}", locDir); return null; } deviceRef[0] = devices.get(0); @@ -714,8 +721,8 @@ protected Smart doInBackground() { Smart.startHeartbeat(); } return Smart.getSmart(deviceRef[0]); - } catch (Exception ex) { - SMART_LOG.log(Level.WARNING, "refreshSmartTab: SMART fetch failed", ex); + } catch (IOException ex) { + SMART_LOG.log(Level.WARNING, "runSmart: SMART fetch failed", ex); return null; } } @@ -736,8 +743,8 @@ protected void done() { lastSmartDeviceName = null; smartPanel.clear(); } - } catch (Exception ex) { - SMART_LOG.log(Level.WARNING, "refreshSmartTab: panel update failed", ex); + } catch (InterruptedException | ExecutionException ex) { + SMART_LOG.log(Level.WARNING, "runSmart: panel update failed", ex); } } }.execute(); diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.form b/jdm-core/src/main/java/jdiskmark/MainFrame.form index bb319ac..e604dd6 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.form +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.form @@ -279,7 +279,7 @@ - + diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index ac3605e..b7ec73c 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -660,7 +660,7 @@ public void actionPerformed(java.awt.event.ActionEvent evt) { optionMenu.add(multiFileCheckBoxMenuItem); smartCbMenuItem.setSelected(true); - smartCbMenuItem.setText("S.M.A.R.T."); + smartCbMenuItem.setText("Run SMART with Benchmark"); smartCbMenuItem.addActionListener(new java.awt.event.ActionListener() { public void actionPerformed(java.awt.event.ActionEvent evt) { smartCbMenuItemActionPerformed(evt); diff --git a/jdm-core/src/main/java/jdiskmark/SmartPanel.java b/jdm-core/src/main/java/jdiskmark/SmartPanel.java index b96787b..454106d 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SmartPanel.java @@ -169,7 +169,7 @@ private JPanel buildToolbar() { statusLabel = new JLabel("Click \u2018Run SMART\u2019 to fetch live data."); statusLabel.setFont(statusLabel.getFont().deriveFont(Font.ITALIC)); - runButton.addActionListener(e -> Gui.refreshSmartTab()); + runButton.addActionListener(e -> Gui.runSmart()); saveButton.addActionListener(e -> Gui.saveCurrentSmartData()); bar.add(runButton); From aeefa48708ec70caa4ba44dae06916785216d1d5 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sun, 28 Jun 2026 21:43:05 -0700 Subject: [PATCH 12/22] #176 Smart changes made --- jdm-core/src/main/java/jdiskmark/SmartPanel.java | 16 ++++++++++++---- .../main/java/jdiskmark/SmartReportsPanel.java | 15 +++++++++------ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/SmartPanel.java b/jdm-core/src/main/java/jdiskmark/SmartPanel.java index 454106d..3993521 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SmartPanel.java @@ -633,10 +633,18 @@ private void fillNvme(Smart.NvmeHealthLog nvme) { nvme.getAvailableSpareThreshold(), true); setNvmeField(usedPctValueLabel, nvme.getPercentageUsed(), "%", null, false); - writtenValueLabel.setText(nvme.getDataWrittenGb() - + " GB (" + nvme.getDataUnitsWritten() + " units)"); - readValueLabel.setText(nvme.getDataReadGb() - + " GB (" + nvme.getDataUnitsRead() + " units)"); + if (nvme.getDataUnitsWritten() != null) { + writtenValueLabel.setText(nvme.getDataWrittenGb() + + " GB (" + nvme.getDataUnitsWritten() + " units)"); + } else { + writtenValueLabel.setText("-"); + } + if (nvme.getDataUnitsRead() != null) { + readValueLabel.setText(nvme.getDataReadGb() + + " GB (" + nvme.getDataUnitsRead() + " units)"); + } else { + readValueLabel.setText("-"); + } setCountField(mediaErrValueLabel, nvme.getMediaErrors()); setCountField(errLogValueLabel, nvme.getNumErrLogEntries()); diff --git a/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java b/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java index e34cbe5..0323c7c 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java @@ -74,6 +74,7 @@ public SmartReportsPanel() { table = new JTable(model); table.setFillsViewportHeight(true); + table.setAutoCreateRowSorter(true); table.setRowHeight(22); table.getColumnModel().getColumn(0).setPreferredWidth(130); table.getColumnModel().getColumn(1).setPreferredWidth(90); @@ -127,16 +128,17 @@ public java.awt.Component getTableCellRendererComponent( // Row selection — load the clicked snapshot into the SMART tab for full replay table.getSelectionModel().addListSelectionListener(e -> { if (e.getValueIsAdjusting()) return; - int row = table.getSelectedRow(); - if (row < 0 || row >= lastLoaded.size()) return; - SmartSnapshot snap = lastLoaded.get(row); + int viewRow = table.getSelectedRow(); + if (viewRow < 0 || viewRow >= lastLoaded.size()) return; + int modelRow = table.convertRowIndexToModel(viewRow); + SmartSnapshot snap = lastLoaded.get(modelRow); Gui.loadSnapshot(snap); }); // Delete listeners — attached here so table/model are guaranteed initialized deleteSelectedBtn.addActionListener(e -> { - int row = table.getSelectedRow(); - if (row < 0 || row >= lastLoaded.size()) { + int viewRow = table.getSelectedRow(); + if (viewRow < 0 || viewRow >= lastLoaded.size()) { javax.swing.JOptionPane.showMessageDialog( Gui.mainFrame, "Please select a row first.", @@ -144,7 +146,8 @@ public java.awt.Component getTableCellRendererComponent( javax.swing.JOptionPane.WARNING_MESSAGE); return; } - SmartSnapshot snap = lastLoaded.get(row); + int modelRow = table.convertRowIndexToModel(viewRow); + SmartSnapshot snap = lastLoaded.get(modelRow); String label = snap.getCapturedAt() != null ? snap.getCapturedAt().format(FMT) : "unknown"; int confirm = javax.swing.JOptionPane.showConfirmDialog( From b5ce9043927ea28a1852472f101e84d9929398ac Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sun, 28 Jun 2026 21:48:09 -0700 Subject: [PATCH 13/22] #176 Smart update for sorting --- jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java b/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java index 0323c7c..c0512f4 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java @@ -73,8 +73,8 @@ public SmartReportsPanel() { }; table = new JTable(model); - table.setFillsViewportHeight(true); table.setAutoCreateRowSorter(true); + table.setFillsViewportHeight(true); table.setRowHeight(22); table.getColumnModel().getColumn(0).setPreferredWidth(130); table.getColumnModel().getColumn(1).setPreferredWidth(90); From 39b9eac4cfed85f71ec70dd3352d183b213652e4 Mon Sep 17 00:00:00 2001 From: JasmineRRod <157640165+JasmineRRod@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:53:08 -0700 Subject: [PATCH 14/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jdm-core/src/main/java/jdiskmark/SmartPanel.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/jdm-core/src/main/java/jdiskmark/SmartPanel.java b/jdm-core/src/main/java/jdiskmark/SmartPanel.java index 3993521..a6a9256 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartPanel.java +++ b/jdm-core/src/main/java/jdiskmark/SmartPanel.java @@ -756,11 +756,17 @@ private void setTempSensorField(JLabel lbl, Integer tempC) { private void setNvmeField(JLabel lbl, Integer value, String suffix, Integer threshold, boolean higherIsBetter) { - if (value == null) { lbl.setText("-"); return; } + if (value == null) { + lbl.setText("-"); + lbl.setForeground(null); + return; + } lbl.setText(value + suffix); if (threshold != null) { boolean warn = higherIsBetter ? value <= threshold : value >= threshold; lbl.setForeground(warn ? new Color(0xF44336) : new Color(0x4CAF50)); + } else { + lbl.setForeground(null); } } From 6d64c89e66ca3a8819359a2404dcbc3b8cf73342 Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 28 Jun 2026 22:12:21 -0700 Subject: [PATCH 15/22] #176 use async solution w comments --- .../main/java/jdiskmark/BenchmarkRunner.java | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index 64e92cd..1fe70fd 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -1,6 +1,5 @@ package jdiskmark; -import java.nio.file.Path; import static jdiskmark.GcDetector.MAX_GC_RETRIES; import java.util.logging.Level; @@ -120,24 +119,11 @@ public Benchmark execute() throws Exception { GcDetector.triggerAndWait(); // Initial cleanup } + // Fetch SMART data before the benchmark starts (Linux only, non-fatal if it fails). + // Gui.runSmart() handles null/missing locationDir, dead privileged shell, and + // device-resolution failures internally — no risk of crashing the benchmark. if (Smart.smartEnable && App.isLinux()) { - String testDir = config.getTestDir(); - String partition = UtilOs.getPartitionFromFilePathLinux(Path.of(testDir)); - String deviceName = UtilOs.getDeviceNamesFromPartitionLinux(partition).get(0); - - // Ensure the privileged bash shell is alive (no-op if already running; - // pkexec prompts the user only on the very first benchmark run). - if (Smart.process == null || !Smart.process.isAlive()) { - Smart.startPrivilegedShell(); - Smart.startHeartbeat(); - } - - Smart smart = Smart.getSmart(deviceName); - - // Populate the SMART tab in the GUI - if (Gui.smartPanel != null && smart != null) { - Gui.smartPanel.populate(smart); - } + Gui.runSmart(); } benchmark.recordStartTime(); From 88a79786a919d26f29bd44d2ebfc373b42a5e88c Mon Sep 17 00:00:00 2001 From: JasmineRRod <157640165+JasmineRRod@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:17:11 -0700 Subject: [PATCH 16/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jdm-core/src/main/java/jdiskmark/SmartSnapshot.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java b/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java index d9019df..0cd9b05 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java +++ b/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java @@ -151,6 +151,10 @@ public SmartSnapshot() {} * @return the persisted snapshot, or {@code null} on error */ public static SmartSnapshot save(Smart smart, String deviceName) { + if (smart == null) { + LOGGER.warning("SmartSnapshot.save: smart is null (device=" + deviceName + ")"); + return null; + } SmartSnapshot snap = new SmartSnapshot(); snap.capturedAt = LocalDateTime.now(); snap.deviceName = deviceName; From 5988ff15e28fad9fc0dc031f5ef24c1e7ef2c39b Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sun, 28 Jun 2026 22:39:52 -0700 Subject: [PATCH 17/22] #176 Smart JPA rollback handling --- .../main/java/jdiskmark/SmartSnapshot.java | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java b/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java index 0cd9b05..23f4df8 100644 --- a/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java +++ b/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java @@ -209,18 +209,25 @@ public static SmartSnapshot save(Smart smart, String deviceName) { // Full JSON for lossless replay snap.rawJson = smart.getRawJson(); + EntityManager em = EM.getEntityManager(); try { - EntityManager em = EM.getEntityManager(); em.getTransaction().begin(); em.persist(snap); em.getTransaction().commit(); LOGGER.info("SmartSnapshot saved: device=" + deviceName + " model=" + snap.modelName + " passed=" + snap.smartPassed); + return snap; } catch (Exception ex) { + try { + if (em.getTransaction().isActive()) { + em.getTransaction().rollback(); + } + } catch (Exception rollbackEx) { + LOGGER.log(Level.WARNING, "SmartSnapshot.save: rollback failed", rollbackEx); + } LOGGER.log(Level.SEVERE, "Failed to persist SmartSnapshot", ex); return null; } - return snap; } /** @@ -240,16 +247,23 @@ public static List findAll() { * @return {@code true} if the record was found and deleted */ public static boolean delete(Long id) { + EntityManager em = EM.getEntityManager(); + SmartSnapshot snap = em.find(SmartSnapshot.class, id); + if (snap == null) return false; try { - EntityManager em = EM.getEntityManager(); - SmartSnapshot snap = em.find(SmartSnapshot.class, id); - if (snap == null) return false; em.getTransaction().begin(); em.remove(snap); em.getTransaction().commit(); LOGGER.info("SmartSnapshot deleted: id=" + id); return true; } catch (Exception ex) { + try { + if (em.getTransaction().isActive()) { + em.getTransaction().rollback(); + } + } catch (Exception rollbackEx) { + LOGGER.log(Level.WARNING, "SmartSnapshot.delete: rollback failed", rollbackEx); + } LOGGER.log(Level.SEVERE, "Failed to delete SmartSnapshot id=" + id, ex); return false; } @@ -261,14 +275,21 @@ public static boolean delete(Long id) { * @return the number of rows deleted */ public static int deleteAll() { + EntityManager em = EM.getEntityManager(); try { - EntityManager em = EM.getEntityManager(); em.getTransaction().begin(); int count = em.createNamedQuery("SmartSnapshot.deleteAll").executeUpdate(); em.getTransaction().commit(); LOGGER.info("SmartSnapshot.deleteAll: removed " + count + " row(s)."); return count; } catch (Exception ex) { + try { + if (em.getTransaction().isActive()) { + em.getTransaction().rollback(); + } + } catch (Exception rollbackEx) { + LOGGER.log(Level.WARNING, "SmartSnapshot.deleteAll: rollback failed", rollbackEx); + } LOGGER.log(Level.SEVERE, "Failed to delete all SmartSnapshots", ex); return 0; } From 20c9c68fea098cc82850640eee6638a80355ede3 Mon Sep 17 00:00:00 2001 From: JasmineRRod <157640165+JasmineRRod@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:45:29 -0700 Subject: [PATCH 18/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jdm-core/src/main/java/jdiskmark/Smart.java | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index c4b68d3..76dad00 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -127,6 +127,27 @@ public static void startPrivilegedShell() throws IOException { new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); shellReader = new BufferedReader( new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + + // Drain stderr so the process can't deadlock if it emits output there. + final BufferedReader errReader = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)); + Thread errThread = new Thread(() -> { + try { + String l; + while ((l = errReader.readLine()) != null) { + LOGGER.warning("[smart-shell] " + l); + } + } catch (IOException e) { + LOGGER.log(Level.FINE, "smart-shell stderr reader stopped", e); + } + }, "smart-shell-stderr"); + errThread.setDaemon(true); + errThread.start(); + + LOGGER.info("Privileged shell started (pid reuse enabled)."); + new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + shellReader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); LOGGER.info("Privileged shell started (pid reuse enabled)."); } } From ccc648046640d0d68ebebc69855e1985948b63ce Mon Sep 17 00:00:00 2001 From: JasmineRRod <157640165+JasmineRRod@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:49:01 -0700 Subject: [PATCH 19/22] #176 Improved error handling Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jdm-core/src/main/java/jdiskmark/Smart.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index 76dad00..49947e2 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -193,9 +193,17 @@ public static void startHeartbeat() { * @return a populated {@link Smart} instance, or {@code null} on error */ public static Smart getSmart(String deviceName) { + if (deviceName == null || !deviceName.matches("[A-Za-z0-9._-]+")) { + LOGGER.severe("getSmart: invalid device name: " + deviceName); + return null; + } final String sentinel = "---SMART_DONE---"; try { synchronized (pLock) { + if (shellWriter == null || shellReader == null) { + LOGGER.severe("getSmart: privileged shell not initialised"); + return null; + } // Write the smartctl command followed by an echo of the sentinel // so we know exactly where the JSON output ends. shellWriter.write(resolveSmartctlPath() + " --json -a /dev/" + deviceName + "\n"); From 6ad3eb42c0a3eb64ffc41177c538853e93a7af04 Mon Sep 17 00:00:00 2001 From: JasmineRRod <157640165+JasmineRRod@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:56:06 -0700 Subject: [PATCH 20/22] #176 Smart initializing info Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- jdm-core/src/main/java/jdiskmark/DrivesPanel.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/DrivesPanel.java b/jdm-core/src/main/java/jdiskmark/DrivesPanel.java index c424681..e1e8d5f 100644 --- a/jdm-core/src/main/java/jdiskmark/DrivesPanel.java +++ b/jdm-core/src/main/java/jdiskmark/DrivesPanel.java @@ -382,9 +382,9 @@ protected String[] doInBackground() { protected void done() { try { String[] r = get(); - infoModelLabel.setText("Model: " + r[0]); - infoPartitionLabel.setText("Partition: " + r[1]); - infoUsageLabel.setText("Usage: " + r[2]); + infoModelLabel.setText("Model: " + ((r[0] != null && !r[0].isBlank()) ? r[0] : "—")); + infoPartitionLabel.setText("Partition: " + ((r[1] != null && !r[1].isBlank()) ? r[1] : "—")); + infoUsageLabel.setText("Usage: " + ((r[2] != null && !r[2].isBlank()) ? r[2] : "—")); int pct = 0; try { pct = Integer.parseInt(r[3]); } catch (NumberFormatException ignore) {} From 1d1b7a5e9382c0c4b22c8690d3993a0d0ffa1cc6 Mon Sep 17 00:00:00 2001 From: Jasmine Date: Sun, 28 Jun 2026 23:02:20 -0700 Subject: [PATCH 21/22] #176 Removed comment --- jdm-core/src/main/java/jdiskmark/App.java | 1 - 1 file changed, 1 deletion(-) diff --git a/jdm-core/src/main/java/jdiskmark/App.java b/jdm-core/src/main/java/jdiskmark/App.java index 13310dd..b624b0f 100644 --- a/jdm-core/src/main/java/jdiskmark/App.java +++ b/jdm-core/src/main/java/jdiskmark/App.java @@ -800,7 +800,6 @@ public static BenchmarkConfig getConfig() { config.gcRetryEnabled = GcDetector.gcRetryEnabled; config.gcHintsEnabled = GcDetector.gcHintsEnabled; config.multiFileEnabled = multiFile; -// config.enabledSmart = Smart.smartEnable; --- TODO in config --- config.testDir = dataDir.getAbsolutePath(); return config; } From 88d91717546accd520f4ab3a74b491e1d75ac410 Mon Sep 17 00:00:00 2001 From: James Mark Chan Date: Sun, 28 Jun 2026 23:34:28 -0700 Subject: [PATCH 22/22] #176 panel default laf, linking benchmark tab on load --- jdm-core/src/main/java/jdiskmark/Gui.java | 16 ++++++++++++++++ jdm-core/src/main/java/jdiskmark/MainFrame.java | 10 +++++++--- .../OperationTableSelectionListener.java | 2 ++ jdm-core/src/main/java/jdiskmark/Smart.java | 4 ---- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java index 632a4b1..eb95e8f 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -677,6 +677,22 @@ static public void updateDiskInfo() { // benchmark when "Run SMART with Benchmark" is enabled. } + /** + * Selects the first tab in {@link #mainTabPane} whose title equals + * {@code tabTitle}. No-op if the pane is null or no matching tab exists. + * + * @param tabTitle the exact tab label to select, e.g. {@code "Benchmark"} + */ + public static void selectMainTab(String tabTitle) { + if (mainTabPane == null) return; + for (int i = 0; i < mainTabPane.getTabCount(); i++) { + if (tabTitle.equals(mainTabPane.getTitleAt(i))) { + mainTabPane.setSelectedIndex(i); + return; + } + } + } + /** * Fetches fresh SMART data for the current drive in a background thread * and populates the SMART panel when done. Triggers the pkexec password diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index b7ec73c..08aeb6a 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -97,6 +97,8 @@ public MainFrame() { benchTab.add(bControlMountPanel, BorderLayout.WEST); benchTab.add(cResultMountPanel, BorderLayout.CENTER); mainTabPane.addTab("Benchmark", benchTab); + // Start on the Benchmark tab — it's the primary interaction surface. + mainTabPane.setSelectedIndex(mainTabPane.getTabCount() - 1); // SMART tab — Linux only (requires smartctl / NVMe kernel support) if (App.isLinux()) { @@ -137,18 +139,20 @@ public MainFrame() { javax.swing.JSplitPane splitPane = new javax.swing.JSplitPane( javax.swing.JSplitPane.VERTICAL_SPLIT, mainTabPane, southPanel); - splitPane.setResizeWeight(0.75); // 75% of space goes to the top pane + splitPane.setResizeWeight(0.0); // all new vertical space goes to the bottom pane splitPane.setDividerSize(6); splitPane.setContinuousLayout(true); - // Set pixel divider position once the frame is realised (height is known) + // Place the divider at the minimum position so the bottom panel gets + // maximum space on first launch. The user can drag it up to expose more + // of the top panel. addComponentListener(new java.awt.event.ComponentAdapter() { private boolean initialised = false; @Override public void componentResized(java.awt.event.ComponentEvent e) { if (!initialised) { initialised = true; - splitPane.setDividerLocation(0.75); + splitPane.setDividerLocation(splitPane.getMinimumDividerLocation()); } } }); diff --git a/jdm-core/src/main/java/jdiskmark/OperationTableSelectionListener.java b/jdm-core/src/main/java/jdiskmark/OperationTableSelectionListener.java index 7f792af..c1fa998 100644 --- a/jdm-core/src/main/java/jdiskmark/OperationTableSelectionListener.java +++ b/jdm-core/src/main/java/jdiskmark/OperationTableSelectionListener.java @@ -35,6 +35,8 @@ public void valueChanged(ListSelectionEvent e) { } else { Gui.loadBenchmark(benchmark); } + // Bring the Benchmark tab to the front so the chart updates are visible. + Gui.selectMainTab("Benchmark"); } } } diff --git a/jdm-core/src/main/java/jdiskmark/Smart.java b/jdm-core/src/main/java/jdiskmark/Smart.java index 49947e2..0a74069 100644 --- a/jdm-core/src/main/java/jdiskmark/Smart.java +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -144,10 +144,6 @@ public static void startPrivilegedShell() throws IOException { errThread.setDaemon(true); errThread.start(); - LOGGER.info("Privileged shell started (pid reuse enabled)."); - new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); - shellReader = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); LOGGER.info("Privileged shell started (pid reuse enabled)."); } }