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/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 diff --git a/jdm-core/src/main/java/jdiskmark/App.java b/jdm-core/src/main/java/jdiskmark/App.java index 2177c97..b624b0f 100644 --- a/jdm-core/src/main/java/jdiskmark/App.java +++ b/jdm-core/src/main/java/jdiskmark/App.java @@ -581,6 +581,7 @@ public static void loadProfile(BenchmarkProfile profile) { writeSyncEnable = profile.isWriteSyncEnable(); sectorAlignment = profile.getSectorAlignment(); multiFile = profile.isMultiFile(); +// Smart.smartEnable = profile.getEnableSmart(); } finally { saveConfig(); } @@ -644,6 +645,9 @@ public static void loadConfig() { value = p.getProperty("multiFile", String.valueOf(multiFile)); multiFile = 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); @@ -742,6 +746,7 @@ public static void saveConfig() { p.setProperty("profileModified", String.valueOf(profileModified)); p.setProperty("benchmarkType", benchmarkType.name()); p.setProperty("multiFile", String.valueOf(multiFile)); + p.setProperty("smartEnable", String.valueOf(Smart.smartEnable)); p.setProperty("autoRemoveData", String.valueOf(autoRemoveData)); p.setProperty("autoReset", String.valueOf(autoReset)); p.setProperty("blockSequence", blockSequence.name()); @@ -814,6 +819,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'); diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index c9d0483..1fe70fd 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -119,6 +119,13 @@ 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()) { + Gui.runSmart(); + } + benchmark.recordStartTime(); // Execution Loops @@ -330,4 +337,4 @@ private void mapEnvironment(Benchmark b, String model, String partId, DiskUsageI b.driveInfo.usedGb = u.usedGb; b.driveInfo.totalGb = u.totalGb; } -} \ No newline at end of file +} 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..e1e8d5f --- /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] != 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) {} + 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 8bb24dd..eb95e8f 100644 --- a/jdm-core/src/main/java/jdiskmark/Gui.java +++ b/jdm-core/src/main/java/jdiskmark/Gui.java @@ -17,8 +17,14 @@ 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; import javax.swing.JProgressBar; import javax.swing.SwingWorker.StateValue; @@ -90,7 +96,20 @@ 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 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 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 #runSmart()} + * 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; @@ -645,9 +664,177 @@ 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 runSmart(), which is called + // by the "Run SMART" button in SmartPanel and optionally after each + // 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 + * 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. + * Called by the "Run SMART" button in {@link SmartPanel} and by + * {@link jdiskmark.BenchmarkRunner} when "Run SMART with Benchmark" is enabled. + */ + 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. + 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() { + try { + Path path = locDir.toPath(); + String partition = UtilOs.getPartitionFromFilePathLinux(path); + List devices = + UtilOs.getDeviceNamesFromPartitionLinux(partition); + if (devices == null || devices.isEmpty()) { + SMART_LOG.log(Level.WARNING, "runSmart: no device for {0}", locDir); + return null; + } + deviceRef[0] = devices.get(0); + if (Smart.process == null || !Smart.process.isAlive()) { + Smart.startPrivilegedShell(); + Smart.startHeartbeat(); + } + return Smart.getSmart(deviceRef[0]); + } catch (IOException ex) { + SMART_LOG.log(Level.WARNING, "runSmart: SMART fetch failed", ex); + return null; + } + } + + @Override + protected void done() { + try { + Smart data = get(); + 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 (InterruptedException | ExecutionException ex) { + SMART_LOG.log(Level.WARNING, "runSmart: 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()); + } } /** @@ -932,6 +1119,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.form b/jdm-core/src/main/java/jdiskmark/MainFrame.form index 03a3acd..e604dd6 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.form +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.form @@ -276,6 +276,15 @@ + + + + + + + + + diff --git a/jdm-core/src/main/java/jdiskmark/MainFrame.java b/jdm-core/src/main/java/jdiskmark/MainFrame.java index 370545c..08aeb6a 100644 --- a/jdm-core/src/main/java/jdiskmark/MainFrame.java +++ b/jdm-core/src/main/java/jdiskmark/MainFrame.java @@ -35,6 +35,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); @@ -42,6 +47,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()); @@ -76,6 +82,31 @@ public MainFrame() { DefaultCaret caret = (DefaultCaret)msgTextArea.getCaret(); 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); + // 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()) { + 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); + } // #117 Sharing tab — added programmatically so the NetBeans form is untouched. sharingPanel = new SharingPanel(); tabbedPane.addTab("Sharing", sharingPanel); @@ -84,8 +115,51 @@ public MainFrame() { portalUploadMenuItem.setVisible(false); portalEndpointMenu.setVisible(false); portalProtocolMenu.setVisible(false); - } + + // 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 (top) and the bottom panel (tabbedPane + + // progress bar) are separated by a draggable vertical JSplitPane divider. + getContentPane().removeAll(); + getContentPane().setLayout(new BorderLayout()); + + JPanel southPanel = new JPanel(new BorderLayout()); + southPanel.add(tabbedPane, BorderLayout.CENTER); + southPanel.add(progressPanel, BorderLayout.SOUTH); + + javax.swing.JSplitPane splitPane = new javax.swing.JSplitPane( + javax.swing.JSplitPane.VERTICAL_SPLIT, mainTabPane, southPanel); + splitPane.setResizeWeight(0.0); // all new vertical space goes to the bottom pane + splitPane.setDividerSize(6); + splitPane.setContinuousLayout(true); + + // 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(splitPane.getMinimumDividerLocation()); + } + } + }); + + getContentPane().add(splitPane, BorderLayout.CENTER); + } + public JPanel getMountPanel() { return cResultMountPanel; } @@ -170,6 +244,7 @@ public void refreshConfig() { } gcHintsCbMenuItem.setSelected(GcDetector.gcHintsEnabled); gcRetryCbMenuItem.setSelected(GcDetector.gcRetryEnabled); + smartCbMenuItem.setSelected(Smart.smartEnable); exportMenu.setEnabled(App.benchmark != null); } @@ -231,6 +306,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 +662,15 @@ public void actionPerformed(java.awt.event.ActionEvent evt) { } }); optionMenu.add(multiFileCheckBoxMenuItem); + + smartCbMenuItem.setSelected(true); + smartCbMenuItem.setText("Run SMART with Benchmark"); + 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"); @@ -1076,6 +1161,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.smartEnable = 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; @@ -1150,6 +1241,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/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 new file mode 100644 index 0000000..0a74069 --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/Smart.java @@ -0,0 +1,1064 @@ +package jdiskmark; + +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.nio.file.Files; +import java.nio.file.Path; +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>}. + * + *

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 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()); + + /** + * 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 + * 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)); + + // 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)."); + } + } + + /** + * 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) { + 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"); + 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 + // ------------------------------------------------------------------------- + + /** 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; + + @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; + + // ── 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 + // ------------------------------------------------------------------------- + + /** 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(); + 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 + // ------------------------------------------------------------------------- + + 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; } + + 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 + // ------------------------------------------------------------------------- + + /** 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; } + + /** 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; + } + } + + // ------------------------------------------------------------------------- + // 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; + + @JsonProperty("temperature_sensors") + private List temperatureSensors; + + 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; } + + public List getTemperatureSensors() { return temperatureSensors; } + public void setTemperatureSensors(List temperatureSensors) { this.temperatureSensors = temperatureSensors; } + + /** 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, + * 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/jdm-core/src/main/java/jdiskmark/SmartPanel.java b/jdm-core/src/main/java/jdiskmark/SmartPanel.java new file mode 100644 index 0000000..a6a9256 --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/SmartPanel.java @@ -0,0 +1,830 @@ +package jdiskmark; + +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; + +/** + * 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 SMART data has + * been retrieved and parsed from {@code smartctl --json -a}. + * + *

Layout sections (scrollable): + *

    + *
  • 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 JPanel { + + // ── Column spec used by every section — keeps labels/values aligned ────── + private static final String COL_SPEC = "[170][grow][170][grow]"; + + // ------------------------------------------------------------------------- + // 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("-"); + + // ------------------------------------------------------------------------- + // 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 + // ------------------------------------------------------------------------- + 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 + // ------------------------------------------------------------------------- + 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 final JLabel tempSensor1ValueLabel = value("-"); + 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 + // ------------------------------------------------------------------------- + private final DefaultTableModel ataModel; + private final JTable ataTable; + private JPanel ataSection; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + public SmartPanel() { + super(new BorderLayout()); + + 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); + + // 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); + + // 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); + + // 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.runSmart()); + saveButton.addActionListener(e -> Gui.saveCurrentSmartData()); + + bar.add(runButton); + bar.add(saveButton); + bar.add(statusLabel, "growx"); + return bar; + } + + // ------------------------------------------------------------------------- + // Layout + // ------------------------------------------------------------------------- + + private void buildLayout(JPanel p) { + + // --- Drive Info section --- + JPanel driveSection = section("Drive Info"); + 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"); + driveSection.add(label("Serial:")); + driveSection.add(serialValueLabel, "growx"); + driveSection.add(label("Firmware:")); + driveSection.add(firmwareValueLabel, "growx"); + driveSection.add(label("Capacity:")); + driveSection.add(capacityValueLabel, "growx, span 3"); + 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", COL_SPEC)); + healthSection.add(label("SMART Status:")); + healthSection.add(statusValueLabel, "growx"); + healthSection.add(label("Temperature:")); + healthSection.add(tempValueLabel, "growx"); + healthSection.add(label("Power-On Hours:")); + healthSection.add(powerOnValueLabel, "growx"); + healthSection.add(label("Power Cycles:")); + healthSection.add(powerCyclesValueLabel, "growx"); + healthSection.add(label("Remaining Life:")); + healthSection.add(remainingLifeValueLabel, "growx, span 3"); + p.add(healthSection, "growx, wrap"); + + // --- 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(label("% Used (PE cycles):")); + nvmeSection.add(usedPctValueLabel, "growx"); + nvmeSection.add(label("Data Written:")); + nvmeSection.add(writtenValueLabel, "growx"); + nvmeSection.add(label("Data Read:")); + nvmeSection.add(readValueLabel, "growx"); + nvmeSection.add(label("Media Errors:")); + nvmeSection.add(mediaErrValueLabel, "growx"); + nvmeSection.add(label("Error Log Entries:")); + 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"); + + // --- 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]")); + JScrollPane ataScroll = new JScrollPane(ataTable); + ataScroll.setPreferredSize(new java.awt.Dimension(600, 200)); + ataSection.add(ataScroll, "grow"); + ataSection.setVisible(false); + p.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); + fillNvmeDevDetails(data); + fillHealth(data); + fillNvme(data.getNvmeHealthLog()); + fillEndurance(data); + fillAtaAttributes(data.getAtaSmartAttributes()); + revalidate(); + repaint(); + }); + } + + /** 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, + 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); + 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(); + }); + } + + // ------------------------------------------------------------------------- + // 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 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) { + 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 — prefer top-level block, fall back to NVMe health log + if (d.getTemperature() != null && d.getTemperature().getCurrent() != null) { + 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 — 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 — 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())); + } + + // 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) { + if (nvme == null) { + nvmeSection.setVisible(false); + return; + } + nvmeSection.setVisible(true); + + setNvmeField(spareValueLabel, nvme.getAvailableSpare(), "%", + nvme.getAvailableSpareThreshold(), true); + setNvmeField(usedPctValueLabel, nvme.getPercentageUsed(), "%", null, false); + + 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()); + + warnTempValueLabel.setText(nvme.getWarningTempTime() != null + ? nvme.getWarningTempTime() + " min" : "-"); + critCompValueLabel.setText(nvme.getCriticalCompTime() != null + ? nvme.getCriticalCompTime() + " min" : "-"); + + setTempSensorField(tempSensor1ValueLabel, nvme.getTemperatureSensor1()); + setTempSensorField(tempSensor2ValueLabel, nvme.getTemperatureSensor2()); + + if (nvme.hasCriticalWarning()) { + statusValueLabel.setText("CRITICAL WARNING (" + nvme.getCriticalWarning() + ") ✘"); + statusValueLabel.setForeground(new Color(0xF44336)); + } + } + + 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()) { + 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 + // ------------------------------------------------------------------------- + + 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("-"); + 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); + } + } + + 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); + } + + private static String orDash(String s) { + return (s != null && !s.isBlank()) ? s : "-"; + } + + private static JLabel label(String text) { + JLabel l = new JLabel(text); + l.setFont(l.getFont().deriveFont(Font.BOLD)); + return l; + } + + private static JLabel value(String text) { return new JLabel(text); } + + private static JPanel section(String title) { + JPanel p = new JPanel(); + p.setBorder(BorderFactory.createTitledBorder( + 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..c0512f4 --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/SmartReportsPanel.java @@ -0,0 +1,241 @@ +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.setAutoCreateRowSorter(true); + 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 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 viewRow = table.getSelectedRow(); + if (viewRow < 0 || viewRow >= lastLoaded.size()) { + javax.swing.JOptionPane.showMessageDialog( + Gui.mainFrame, + "Please select a row first.", + "No Selection", + javax.swing.JOptionPane.WARNING_MESSAGE); + return; + } + 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( + 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..23f4df8 --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/SmartSnapshot.java @@ -0,0 +1,320 @@ +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) { + 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; + 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(); + + EntityManager em = EM.getEntityManager(); + try { + 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; + } + } + + /** + * 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) { + EntityManager em = EM.getEntityManager(); + SmartSnapshot snap = em.find(SmartSnapshot.class, id); + if (snap == null) return false; + try { + 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; + } + } + + /** + * Deletes all stored snapshots in a single bulk operation. + * + * @return the number of rows deleted + */ + public static int deleteAll() { + EntityManager em = EM.getEntityManager(); + try { + 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; + } + } + + // ------------------------------------------------------------------------- + // 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; } +} 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 ;; *)