From cbebd5147c6e6993fed67152de6d1443b2c2575e Mon Sep 17 00:00:00 2001 From: MacRimi Date: Thu, 16 Apr 2026 17:36:23 +0200 Subject: [PATCH] update storage-overview.tsx --- AppImage/components/storage-overview.tsx | 377 +++++++++++++----- AppImage/scripts/flask_server.py | 468 +++++++++++++++++++---- 2 files changed, 664 insertions(+), 181 deletions(-) diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index a739351f..43dc652c 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -1858,10 +1858,13 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri } } - // Determine disk type + // Determine disk type (SAS detected via backend flag or connection_type) + const isSasDisk = sd?.is_sas === true || disk.connection_type === 'sas' let diskType = "HDD" if (disk.name.startsWith("nvme")) { diskType = "NVMe" + } else if (isSasDisk) { + diskType = "SAS" } else if (!disk.rotation_rate || disk.rotation_rate === 0) { diskType = "SSD" } @@ -1888,71 +1891,213 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri // Explanations for NVMe metrics const nvmeExplanations: Record = { 'Critical Warning': 'Active alert flags from the NVMe controller. Any non-zero value requires immediate investigation.', - 'Temperature': 'Composite temperature. Sustained high temps cause throttling and reduce lifespan.', - 'Available Spare': 'Spare NAND blocks remaining. Alert triggers below 5%.', - 'Available Spare Threshold': 'Threshold below which spare blocks are considered critical.', - 'Percentage Used': "Drive's own estimate of endurance consumed. 100% means rated lifespan has been reached.", - 'Percent Used': "Drive's own estimate of endurance consumed. 100% means rated lifespan has been reached.", - 'Media Errors': 'Unrecoverable errors involving the NAND flash. Any non-zero value indicates flash cell damage.', - 'Unsafe Shutdowns': 'Power losses without proper shutdown. Very high counts can cause firmware corruption.', + 'Temperature': 'Composite temperature reported by the controller. Sustained high temps cause thermal throttling and reduce NAND lifespan.', + 'Temperature Sensor 1': 'Primary temperature sensor, usually the NAND flash. Most representative of flash health.', + 'Temperature Sensor 2': 'Secondary sensor, often the controller die. Typically runs hotter than Sensor 1.', + 'Temperature Sensor 3': 'Tertiary sensor, if present. Location varies by manufacturer.', + 'Available Spare': 'Percentage of spare NAND blocks remaining for bad-block replacement. Alert triggers below threshold.', + 'Available Spare Threshold': 'Manufacturer-set minimum for Available Spare. Below this, the drive flags a critical warning.', + 'Percentage Used': "Drive's own estimate of endurance consumed based on actual vs. rated write cycles. 100% = rated TBW reached; drive may continue working beyond this.", + 'Percent Used': "Drive's own estimate of endurance consumed based on actual vs. rated write cycles. 100% = rated TBW reached; drive may continue working beyond this.", + 'Media Errors': 'Unrecoverable read/write errors on the NAND flash. Any non-zero value indicates permanent cell damage. Growing count = replace soon.', + 'Media and Data Integrity Errors': 'Unrecoverable errors detected by the controller. Non-zero means data corruption risk.', + 'Unsafe Shutdowns': 'Power losses without proper flush/shutdown. Very high counts risk metadata corruption and firmware issues.', 'Power Cycles': 'Total on/off cycles. Frequent cycling increases connector and capacitor wear.', - 'Power On Hours': 'Total hours the drive has been powered on.', - 'Data Units Read': 'Total data read from the drive in 512KB units.', - 'Data Units Written': 'Total data written to the drive in 512KB units.', - 'Host Read Commands': 'Total read commands processed by the controller.', - 'Host Write Commands': 'Total write commands processed by the controller.', - 'Controller Busy Time': 'Minutes the controller was busy processing commands.', - 'Error Log Entries': 'Number of entries in the error log. Often includes benign self-test artifacts.', - 'Warning Temp Time': 'Minutes spent in the warning temperature range. Zero is ideal.', - 'Critical Temp Time': 'Minutes spent in the critical temperature range. Should always be zero.', + 'Power On Hours': 'Total cumulative hours the drive has been powered on since manufacture.', + 'Data Units Read': 'Total data read in 512KB units. Multiply by 512,000 for bytes. Useful for calculating daily read workload.', + 'Data Units Written': 'Total data written in 512KB units. Compare with TBW rating to estimate remaining endurance.', + 'Host Read Commands': 'Total read commands issued by the host. High ratio vs. write commands indicates read-heavy workload.', + 'Host Write Commands': 'Total write commands issued by the host. Includes filesystem metadata writes.', + 'Controller Busy Time': 'Total minutes the controller spent processing I/O commands. High values indicate sustained heavy workload.', + 'Error Log Entries': 'Number of entries in the error information log. Often includes benign self-test artifacts; cross-check with Media Errors.', + 'Error Information Log Entries': 'Number of entries in the error information log. Often includes benign self-test artifacts.', + 'Warning Temp Time': 'Total minutes spent above the warning temperature threshold. Causes performance throttling. Zero is ideal.', + 'Critical Temp Time': 'Total minutes spent above the critical temperature threshold. Drive may shut down to prevent damage. Should always be zero.', + 'Warning Composite Temperature Time': 'Total minutes the composite temperature exceeded the warning threshold.', + 'Critical Composite Temperature Time': 'Total minutes the composite temperature exceeded the critical threshold. Must be zero.', + 'Thermal Management T1 Trans Count': 'Number of times the drive entered light thermal throttling (T1). Indicates cooling issues.', + 'Thermal Management T2 Trans Count': 'Number of times the drive entered heavy thermal throttling (T2). Significant performance impact.', + 'Thermal Management T1 Total Time': 'Total seconds spent in light thermal throttling. Indicates sustained cooling problems.', + 'Thermal Management T2 Total Time': 'Total seconds spent in heavy thermal throttling. Severe performance degradation.', } - // Explanations for SATA/SSD attributes + // Explanations for SATA/SSD attributes — covers HDD, SSD, and mixed-use attributes const sataExplanations: Record = { - 'Raw Read Error Rate': 'Raw read errors detected. High values on Seagate drives are often normal (uses proprietary formula).', + // === Read/Write Errors === + 'Raw Read Error Rate': 'Hardware read errors detected. High raw values on Seagate/Samsung drives are normal (proprietary formula where VALUE, not raw, matters).', + 'Write Error Rate': 'Errors encountered during write operations. Growing count may indicate head or media issues.', + 'Multi Zone Error Rate': 'Errors when writing to multi-zone regions. Manufacturer-specific; rising trend is concerning.', + 'Soft Read Error Rate': 'Read errors corrected by firmware without data loss. High values may indicate degrading media.', + 'Read Error Retry Rate': 'Number of read retries needed. Occasional retries are normal; persistent growth indicates wear.', + 'Reported Uncorrect': 'Errors that ECC could not correct. Any non-zero value means data was lost or unreadable.', + 'Reported Uncorrectable Errors': 'Errors that ECC could not correct. Non-zero = data loss risk.', + + // === Reallocated / Pending / Offline === + 'Reallocated Sector Ct': 'Bad sectors replaced by spare sectors from the reserve pool. Growing count = drive degradation.', 'Reallocated Sector Count': 'Bad sectors replaced by spare sectors. Growing count indicates drive degradation.', 'Reallocated Sectors': 'Bad sectors replaced by spare sectors. Growing count indicates drive degradation.', - 'Spin Up Time': 'Time needed for platters to reach operating speed (HDD only).', - 'Start Stop Count': 'Number of spindle start/stop cycles (HDD only).', - 'Power On Hours': 'Total hours the drive has been powered on.', - 'Power Cycle Count': 'Total number of complete power on/off cycles.', - 'Temperature': 'Current drive temperature. High temps reduce lifespan.', - 'Temperature Celsius': 'Current drive temperature in Celsius. High temps reduce lifespan.', - 'Current Pending Sector': 'Sectors waiting to be remapped. May resolve or become reallocated.', + 'Retired Block Count': 'NAND blocks retired due to wear or failure (SSD). Similar to Reallocated Sector Count for HDDs.', + 'Reallocated Event Count': 'Number of remap operations performed. Each event means a bad sector was replaced.', + 'Current Pending Sector': 'Unstable sectors waiting to be remapped on next write. May resolve or become permanently reallocated.', + 'Current Pending Sector Count': 'Unstable sectors waiting to be remapped on next write. Non-zero warrants monitoring.', 'Pending Sectors': 'Sectors waiting to be remapped. May resolve or become reallocated.', - 'Offline Uncorrectable': 'Uncorrectable errors found during offline scan. Indicates potential data loss.', - 'UDMA CRC Error Count': 'Interface communication errors. Usually caused by cable or connection issues.', + 'Offline Uncorrectable': 'Sectors that failed during offline scan and could not be corrected. Indicates potential data loss.', + 'Offline Uncorrectable Sector Count': 'Uncorrectable sectors found during background scan. Data on these sectors is lost.', + + // === Temperature === + 'Temperature': 'Current drive temperature. Sustained high temps accelerate wear and reduce lifespan.', + 'Temperature Celsius': 'Current drive temperature in Celsius. HDDs: keep below 45°C; SSDs: below 60°C.', + 'Airflow Temperature Cel': 'Temperature measured by the airflow sensor. Usually slightly lower than the main temp sensor.', + 'Temperature Case': 'Temperature of the drive casing. Useful for monitoring enclosure ventilation.', + 'Temperature Internal': 'Internal temperature sensor. May read higher than case temperature.', + + // === Power & Uptime === + 'Power On Hours': 'Total cumulative hours the drive has been powered on. Used to estimate age and plan replacements.', + 'Power On Hours and Msec': 'Total powered-on time with millisecond precision.', + 'Power Cycle Count': 'Total number of complete power on/off cycles. Frequent cycling stresses electronics.', + 'Power Off Retract Count': 'Times the heads were retracted due to power loss (HDD). High values indicate unstable power supply.', + 'Unexpected Power Loss Ct': 'Unexpected power losses (SSD). Can cause metadata corruption if write-cache was active.', + 'Unsafe Shutdown Count': 'Power losses without proper shutdown (SSD). High values risk firmware corruption.', + 'Start Stop Count': 'Spindle motor start/stop cycles (HDD). Each cycle causes mechanical wear.', + + // === Mechanical (HDD-specific) === + 'Spin Up Time': 'Time for platters to reach full operating speed (HDD). Increasing values may indicate motor bearing wear.', + 'Spin Retry Count': 'Failed attempts to spin up the motor (HDD). Non-zero usually indicates power supply or motor issues.', + 'Calibration Retry Count': 'Number of head calibration retries (HDD). Non-zero may indicate mechanical issues.', + 'Seek Error Rate': 'Errors during head positioning (HDD). High raw values on Seagate are often normal (proprietary formula).', + 'Seek Time Performance': 'Average seek operation performance (HDD). Declining values suggest mechanical degradation.', + 'Load Cycle Count': 'Head load/unload cycles (HDD). Rated for 300K-600K cycles on most drives.', + 'Load Unload Cycle Count': 'Head load/unload cycles (HDD). Each cycle causes micro-wear on the ramp mechanism.', + 'Head Flying Hours': 'Hours the read/write heads have been positioned over the platters (HDD).', + 'High Fly Writes': 'Writes where the head flew higher than expected (HDD). Data may not be written correctly.', + 'G Sense Error Rate': 'Shock/vibration events detected by the accelerometer (HDD). High values indicate physical disturbance.', + 'Disk Shift': 'Distance the disk has shifted from its original position (HDD). Temperature or shock-related.', + 'Loaded Hours': 'Hours spent with heads loaded over the platters (HDD).', + 'Load In Time': 'Time of the head loading process. Manufacturer-specific diagnostic metric.', + 'Torque Amplification Count': 'Times the drive needed extra torque to spin up. May indicate stiction or motor issues.', + 'Flying Height': 'Head-to-platter distance during operation (HDD). Critical for read/write reliability.', + 'Load Friction': 'Friction detected during head loading (HDD). Increasing values suggest ramp mechanism wear.', + 'Load Unload Retry Count': 'Failed head load/unload attempts (HDD). Non-zero indicates mechanical issues.', + + // === Interface Errors === + 'UDMA CRC Error Count': 'Data transfer checksum errors on the SATA cable. Usually caused by a bad cable, loose connection, or port issue.', 'CRC Errors': 'Interface communication errors. Usually caused by cable or connection issues.', - 'Wear Leveling Count': 'SSD wear indicator. Lower values mean more wear.', - 'Media Wearout Indicator': 'SSD life remaining estimate. Lower values mean less life remaining.', - 'Total LBAs Written': 'Total logical blocks written to the drive.', - 'Total LBAs Read': 'Total logical blocks read from the drive.', - 'SSD Life Left': 'Estimated remaining lifespan percentage.', - 'Percent Lifetime Remain': 'Estimated remaining lifespan percentage.', + 'CRC Error Count': 'Data transfer checksum errors. Replace the SATA cable if this value grows.', + 'Command Timeout': 'Commands that took too long and timed out. May indicate controller or connection issues.', + 'Interface CRC Error Count': 'CRC errors on the interface link. Cable or connector problem.', + + // === ECC & Data Integrity === + 'Hardware ECC Recovered': 'Read errors corrected by hardware ECC. Non-zero is normal; rapid growth warrants attention.', + 'ECC Error Rate': 'Rate of ECC-corrected errors. Proprietary formula; VALUE matters more than raw count.', + 'End to End Error': 'Data corruption detected between the controller cache and host interface. Should always be zero.', + 'End to End Error Detection Count': 'Number of parity errors in the data path. Non-zero indicates controller issues.', + + // === SSD Wear & Endurance === + 'Wear Leveling Count': 'Average erase cycles per NAND block (SSD). Lower VALUE = more wear consumed.', + 'Wear Range Delta': 'Difference between most-worn and least-worn blocks (SSD). High values indicate uneven wear.', + 'Media Wearout Indicator': 'Intel SSD life remaining estimate. Starts at 100, decreases to 0 as endurance is consumed.', + 'SSD Life Left': 'Estimated remaining SSD lifespan percentage based on NAND wear.', + 'Percent Lifetime Remain': 'Estimated remaining lifespan percentage. 100 = new; 0 = end of rated life.', + 'Percent Lifetime Used': 'Percentage of rated endurance consumed. Inverse of Percent Lifetime Remain.', + 'Available Reservd Space': 'Remaining spare blocks as a percentage of total reserves (SSD). Similar to NVMe Available Spare.', + 'Available Reserved Space': 'Remaining spare blocks as a percentage (SSD). Low values reduce the drive\'s ability to handle bad blocks.', + 'Used Rsvd Blk Cnt Tot': 'Total reserve blocks consumed for bad-block replacement (SSD). Growing = aging.', + 'Used Reserved Block Count': 'Number of reserve blocks used for bad-block replacement (SSD).', + 'Unused Rsvd Blk Cnt Tot': 'Remaining reserve blocks available (SSD). Zero = no more bad-block replacement possible.', + 'Unused Reserve Block Count': 'Reserve blocks still available for bad-block replacement (SSD).', + 'Program Fail Cnt Total': 'Total NAND program (write) failures (SSD). Non-zero indicates flash cell degradation.', + 'Program Fail Count': 'NAND write failures (SSD). Growing count means flash cells are wearing out.', + 'Program Fail Count Chip': 'Program failures at chip level (SSD). Non-zero indicates NAND degradation.', + 'Erase Fail Count': 'NAND erase operation failures (SSD). Non-zero indicates severe flash wear.', + 'Erase Fail Count Total': 'Total NAND erase failures (SSD). Combined with Program Fail Count shows overall NAND health.', + 'Erase Fail Count Chip': 'Erase failures at chip level (SSD). Non-zero = NAND degradation.', + 'Runtime Bad Block': 'Bad blocks discovered during normal operation (SSD). Different from factory-mapped bad blocks.', + 'Runtime Bad Blocks': 'Blocks that failed during use (SSD). Growing count = flash wearing out.', + + // === Data Volume === + 'Total LBAs Written': 'Total logical block addresses written. Multiply by 512 bytes for total data volume.', + 'Total LBAs Read': 'Total logical block addresses read. Useful for calculating daily workload.', + 'Lifetime Writes GiB': 'Total data written in GiB over the drive\'s lifetime.', + 'Lifetime Reads GiB': 'Total data read in GiB over the drive\'s lifetime.', + 'Total Writes GiB': 'Total data written in GiB. Compare with TBW rating for endurance estimate.', + 'Total Reads GiB': 'Total data read in GiB.', + 'NAND Writes GiB': 'Raw NAND writes in GiB. Higher than host writes due to write amplification.', + 'Host Writes 32MiB': 'Total data written by the host in 32MiB units.', + 'Host Reads 32MiB': 'Total data read by the host in 32MiB units.', + 'Host Writes MiB': 'Total data written by the host in MiB.', + 'Host Reads MiB': 'Total data read by the host in MiB.', + 'NAND GB Written TLC': 'Total data written to TLC NAND cells in GB. Includes write amplification overhead.', + 'NAND GiB Written': 'Total NAND writes in GiB. Higher than host writes due to write amplification and garbage collection.', + + // === SSD-Specific Advanced === + 'Ave Block Erase Count': 'Average number of erase cycles per NAND block (SSD). Drives are typically rated for 3K-100K cycles.', + 'Average Erase Count': 'Average erase cycles per block. Compare with rated endurance for remaining life estimate.', + 'Max Erase Count': 'Maximum erase cycles on any single block. Large gap with average indicates uneven wear.', + 'Total Erase Count': 'Sum of all erase cycles across all blocks. Overall NAND write volume indicator.', + 'Power Loss Cap Test': 'Result of the power-loss protection capacitor self-test (SSD). Failed = risk of data loss on power failure.', + 'Power Loss Protection': 'Status of the power-loss protection mechanism. Enterprise SSDs use capacitors to flush cache on power loss.', + 'Successful RAIN Recov Cnt': 'Successful recoveries using RAIN (Redundant Array of Independent NAND). Shows NAND parity is working.', + 'SSD Erase Fail Count': 'Total erase failures across the SSD. Indicates overall NAND degradation.', + 'SSD Program Fail Count': 'Total write failures across the SSD. Indicates flash cell reliability issues.', + + // === Throughput === + 'Throughput Performance': 'Overall throughput performance rating (HDD). Declining values indicate degradation.', + + // === Other / Vendor-specific === + 'Unknown Attribute': 'Vendor-specific attribute not defined in the SMART standard. Check manufacturer documentation.', + 'Free Fall Sensor': 'Free-fall events detected (laptop HDD). The heads are parked to prevent damage during drops.', } - const getAttrExplanation = (name: string, isNvme: boolean): string => { + // Explanations for SAS/SCSI metrics + const sasExplanations: Record = { + 'Grown Defect List': 'Sectors remapped due to defects found during operation. Equivalent to Reallocated Sectors on SATA. Growing count = drive degradation.', + 'Read Errors Corrected': 'Read errors corrected by ECC. Normal for enterprise drives under heavy workload — only uncorrected errors are critical.', + 'Read ECC Fast': 'Errors corrected by fast (on-the-fly) ECC during read operations. Normal in SAS drives.', + 'Read ECC Delayed': 'Errors requiring delayed (offline) ECC correction during reads. Non-zero is acceptable but should not grow rapidly.', + 'Read Uncorrected Errors': 'Read errors that ECC could not correct. Non-zero means data was lost or unreadable. Critical metric.', + 'Read Data Processed': 'Total data read by the drive. Useful for calculating daily workload.', + 'Write Errors Corrected': 'Write errors corrected by ECC. Normal for enterprise drives.', + 'Write Uncorrected Errors': 'Write errors that ECC could not correct. Non-zero = potential data loss. Critical.', + 'Write Data Processed': 'Total data written to the drive. Useful for workload analysis.', + 'Verify Errors Corrected': 'Verification errors corrected during background verify operations.', + 'Verify Uncorrected Errors': 'Verify errors that could not be corrected. Non-zero indicates media degradation.', + 'Non-Medium Errors': 'Controller/bus errors not related to the media itself. High count may indicate backplane or cable issues.', + 'Temperature': 'Current drive temperature. Enterprise SAS drives tolerate up to 55-60°C under sustained load.', + 'Power On Hours': 'Total hours the drive has been powered on. Enterprise drives are rated for 24/7 operation.', + 'Start-Stop Cycles': 'Motor start/stop cycles. Enterprise SAS drives are rated for 50,000+ cycles.', + 'Load-Unload Cycles': 'Head load/unload cycles. Enterprise drives are rated for 600,000+ cycles.', + 'Background Scan Status': 'Status of the SCSI background media scan. Runs continuously to detect surface defects.', + } + + const getAttrExplanation = (name: string, diskKind: string): string => { const cleanName = name.replace(/_/g, ' ') - if (isNvme) { + if (diskKind === 'NVMe') { return nvmeExplanations[cleanName] || nvmeExplanations[name] || '' } + if (diskKind === 'SAS') { + return sasExplanations[cleanName] || sasExplanations[name] || '' + } return sataExplanations[cleanName] || sataExplanations[name] || '' } - + + // SAS and NVMe use simplified table format (Metric | Value | Status) + const useSimpleTable = isNvmeForTable || isSasDisk + const attributeRows = smartAttributes.map((attr, i) => { const statusColor = attr.status === 'ok' ? '#16a34a' : attr.status === 'warning' ? '#ca8a04' : '#dc2626' const statusBg = attr.status === 'ok' ? '#16a34a15' : attr.status === 'warning' ? '#ca8a0415' : '#dc262615' - const explanation = getAttrExplanation(attr.name, isNvmeForTable) - - if (isNvmeForTable) { - // NVMe format: Metric | Value | Status (with explanation) + const explanation = getAttrExplanation(attr.name, diskType) + + if (useSimpleTable) { + // NVMe/SAS format: Metric | Value | Status (with explanation) + const displayValue = isSasDisk ? attr.raw_value : attr.value return `
${attr.name}
${explanation ? `
${explanation}
` : ''} - ${attr.value} + ${displayValue} ${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()} ` @@ -1967,7 +2112,7 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri ${attr.value} ${attr.worst} - ${attr.threshold} + ${attr.threshold} ${attr.raw_value} ${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()} @@ -1993,6 +2138,11 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri if (temp <= 59) return '#16a34a' if (temp <= 70) return '#ca8a04' return '#dc2626' + case 'SAS': + // SAS enterprise: <=55 green, 56-65 yellow, >65 red + if (temp <= 55) return '#16a34a' + if (temp <= 65) return '#ca8a04' + return '#dc2626' case 'HDD': default: // HDD: <=45 green, 46-55 yellow, >55 red @@ -2003,12 +2153,14 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri } // Temperature thresholds for display - const tempThresholds = diskType === 'NVMe' + const tempThresholds = diskType === 'NVMe' ? { optimal: '<=70°C', warning: '71-80°C', critical: '>80°C' } : diskType === 'SSD' ? { optimal: '<=59°C', warning: '60-70°C', critical: '>70°C' } + : diskType === 'SAS' + ? { optimal: '<=55°C', warning: '56-65°C', critical: '>65°C' } : { optimal: '<=45°C', warning: '46-55°C', critical: '>55°C' } - + const isNvmeDisk = diskType === 'NVMe' // NVMe Wear & Lifetime data @@ -2220,31 +2372,46 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; } @page { margin: 10mm; size: A4; } @media print { + html, body { margin: 0 !important; padding: 0 !important; } .no-print { display: none !important; } .page-break { page-break-before: always; } * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } - body { font-size: 11px; padding-top: 0; max-width: none; width: 100%; } + body { font-size: 11px; padding-top: 0; } .section { page-break-inside: avoid; break-inside: avoid; } - .grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr !important; } - .grid-3 { grid-template-columns: 1fr 1fr 1fr !important; } - .grid-2 { grid-template-columns: 1fr 1fr !important; } - .rpt-header { flex-direction: row !important; } - .hide-mobile { display: table-cell !important; } + .exec-box { page-break-inside: avoid; break-inside: avoid; } + .card { page-break-inside: avoid; break-inside: avoid; } + .grid-2, .grid-3, .grid-4 { page-break-inside: avoid; break-inside: avoid; } + .section-title { page-break-after: avoid; break-after: avoid; } + .attr-tbl tr { page-break-inside: avoid; break-inside: avoid; } + .attr-tbl thead { display: table-header-group; } + .rpt-footer { page-break-inside: avoid; break-inside: avoid; margin-top: 20px; } + .section { margin-bottom: 15px; } + svg { max-width: 100%; height: auto; } + /* Darken light grays for PDF readability */ + .rpt-header-left p, .rpt-header-right { color: #374151; } + .rpt-header-right .rid { color: #4b5563; } + .exec-text p { color: #374151; } + .card-label { color: #4b5563; } + .rpt-footer { color: #4b5563; } + [style*="color:#64748b"] { color: #374151 !important; } + [style*="color:#94a3b8"] { color: #4b5563 !important; } + [style*="color: #64748b"] { color: #374151 !important; } + [style*="color: #94a3b8"] { color: #4b5563 !important; } + [style*="color:#16a34a"], [style*="color: #16a34a"] { color: #16a34a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + [style*="color:#dc2626"] { color: #dc2626 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + [style*="color:#ca8a04"] { color: #ca8a04 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + .health-ring, .card-value, .f-tag { -webkit-print-color-adjust: exact; print-color-adjust: exact; } } @media screen { body { max-width: 1000px; margin: 0 auto; padding: 24px 32px; padding-top: 64px; overflow-x: hidden; } } - @media screen and (max-width: 640px) { - body { padding: 16px; padding-top: 64px; } - .grid-4 { grid-template-columns: 1fr 1fr; } - .rpt-header { flex-direction: column; gap: 12px; align-items: flex-start; } - .rpt-header-right { text-align: left; } - } - - /* Top bar */ + @media print { .top-bar { display: none; } body { padding-top: 0; } } + + /* Top bar for screen only */ .top-bar { position: fixed; top: 0; left: 0; right: 0; background: #0f172a; color: #e2e8f0; - padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; z-index: 100; + padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; z-index: 100; + font-size: 13px; } .top-bar-left { display: flex; align-items: center; gap: 12px; } .top-bar-title { font-weight: 600; } @@ -2254,7 +2421,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri font-size: 14px; font-weight: 600; cursor: pointer; } .top-bar button:hover { background: #0891b2; } - @media print { .top-bar { display: none; } body { padding-top: 0; } } /* Header */ .rpt-header { @@ -2279,7 +2445,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri .exec-box { display: flex; align-items: flex-start; gap: 20px; padding: 20px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 16px; - flex-wrap: wrap; } .health-ring { width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column; @@ -2311,15 +2476,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri .attr-tbl tr:hover { background: #f8fafc; } .attr-tbl .col-name { word-break: break-word; } .attr-tbl .col-raw { font-family: monospace; font-size: 10px; } - .hide-mobile { display: table-cell; } - @media screen and (max-width: 640px) { - .hide-mobile { display: none !important; } - .attr-tbl { font-size: 11px; } - .attr-tbl th { font-size: 11px; padding: 5px 3px; } - .attr-tbl td { padding: 5px 3px; } - .attr-tbl .col-name { padding-right: 6px; } - .attr-tbl .col-raw { font-size: 11px; word-break: break-all; } - } /* Recommendations */ .rec-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px; border-radius: 6px; margin-bottom: 8px; } @@ -2340,16 +2496,32 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-between; font-size: 10px; color: #94a3b8; } + + /* NOTE: No mobile-specific layout overrides — print layout is always A4/desktop + regardless of the device generating the PDF. The @media print block above + handles all necessary print adjustments. */ + + +
-
-
SMART Health Report
-
/dev/${disk.name}
+
+ SMART Health Report + /dev/${disk.name}
- +
@@ -2463,15 +2635,16 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
Type
-
${diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}
+
${diskType === 'SAS' ? (disk.rotation_rate ? `SAS ${disk.rotation_rate} RPM` : 'SAS SSD') : diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? `
${modelFamily ? `
Family
${modelFamily}
` : ''} ${formFactor ? `
Form Factor
${formFactor}
` : ''} - ${sataVersion ? `
Interface
${sataVersion}${ifaceSpeed ? ` · ${ifaceSpeed}` : ''}
` : (ifaceSpeed ? `
Link Speed
${ifaceSpeed}
` : '')} - ${!isNvmeDisk ? `
TRIM
${trimSupported ? 'Supported' : 'Not supported'}${physBlockSize === 4096 ? ' · 4K AF' : ''}
` : ''} + ${sataVersion ? `
Interface
${sataVersion}${ifaceSpeed ? ` · ${ifaceSpeed}` : ''}
` : (ifaceSpeed ? `
${isSasDisk ? 'Transport' : 'Link Speed'}
${ifaceSpeed}
` : '')} + ${!isNvmeDisk && !isSasDisk ? `
TRIM
${trimSupported ? 'Supported' : 'Not supported'}${physBlockSize === 4096 ? ' · 4K AF' : ''}
` : ''} + ${isSasDisk && sd?.logical_block_size ? `
Block Size
${sd.logical_block_size} bytes
` : ''}
` : ''}
@@ -2498,15 +2671,15 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
${disk.pending_sectors ?? 0}
-
Pending Sectors
+
${isSasDisk ? 'Uncorrected Errors' : 'Pending Sectors'}
-
${disk.crc_errors ?? 0}
+
${isSasDisk ? 'N/A' : (disk.crc_errors ?? 0)}
CRC Errors
${disk.reallocated_sectors ?? 0}
-
Reallocated Sectors
+
${isSasDisk ? 'Grown Defects' : 'Reallocated Sectors'}
` : ''} @@ -2722,23 +2895,23 @@ ${!isNvmeDisk && diskType === 'SSD' ? (() => { return '' })() : ''} - +
-
${isNvmeDisk ? '4' : (diskType === 'SSD' && (disk.wear_leveling_count !== undefined || disk.ssd_life_left !== undefined || smartAttributes.some(a => a.name?.toLowerCase().includes('wear'))) ? '4' : '3')}. ${isNvmeDisk ? 'NVMe Health Metrics' : 'SMART Attributes'} (${smartAttributes.length} total${hasCritical ? `, ${criticalAttrs.length} warning(s)` : ''})
+
${isNvmeDisk ? '4' : (diskType === 'SSD' && (disk.wear_leveling_count !== undefined || disk.ssd_life_left !== undefined || smartAttributes.some(a => a.name?.toLowerCase().includes('wear'))) ? '4' : '3')}. ${isNvmeDisk ? 'NVMe Health Metrics' : isSasDisk ? 'SAS/SCSI Health Metrics' : 'SMART Attributes'} (${smartAttributes.length} total${hasCritical ? `, ${criticalAttrs.length} warning(s)` : ''})
- ${isNvmeDisk ? '' : ''} - - - ${isNvmeDisk ? '' : ''} - ${isNvmeDisk ? '' : ''} - ${isNvmeDisk ? '' : ''} + ${useSimpleTable ? '' : ''} + + + ${useSimpleTable ? '' : ''} + ${useSimpleTable ? '' : ''} + ${useSimpleTable ? '' : ''} - ${attributeRows || ''} + ${attributeRows || ''}
ID${isNvmeDisk ? 'Metric' : 'Attribute'}ValueWorstThrRawID${isNvmeDisk ? 'Metric' : isSasDisk ? 'Metric' : 'Attribute'}ValueWorstThrRaw
No ' + (isNvmeDisk ? 'NVMe metrics' : 'SMART attributes') + ' available
No ' + (isNvmeDisk ? 'NVMe metrics' : isSasDisk ? 'SAS metrics' : 'SMART attributes') + ' available
@@ -2938,6 +3111,8 @@ interface SmartTestStatus { self_test_history?: SmartSelfTestEntry[] attributes: SmartAttribute[] nvme_raw?: NvmeRaw + is_sas?: boolean + logical_block_size?: number } tools_installed?: { smartctl: boolean @@ -3261,23 +3436,23 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro

- {isNvme ? 'NVMe Health Metrics' : 'SMART Attributes'} + {isNvme ? 'NVMe Health Metrics' : testStatus.smart_data?.is_sas ? 'SAS/SCSI Health Metrics' : 'SMART Attributes'}

-
- {!isNvme &&
ID
} -
Attribute
-
Value
- {!isNvme &&
Worst
} +
+ {!isNvme && !testStatus.smart_data?.is_sas &&
ID
} +
Attribute
+
Value
+ {!isNvme && !testStatus.smart_data?.is_sas &&
Worst
}
Status
{testStatus.smart_data.attributes.slice(0, 15).map((attr) => ( -
- {!isNvme &&
{attr.id}
} -
{attr.name}
-
{attr.value}
- {!isNvme &&
{attr.worst}
} +
+ {!isNvme && !testStatus.smart_data?.is_sas &&
{attr.id}
} +
{attr.name}
+
{testStatus.smart_data?.is_sas ? attr.raw_value : attr.value}
+ {!isNvme && !testStatus.smart_data?.is_sas &&
{attr.worst}
}
{attr.status === 'ok' ? ( diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index aa411788..585b8628 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -2673,7 +2673,8 @@ def get_smart_data(disk_name): try: commands_to_try = [ - ['smartctl', '-a', '-j', f'/dev/{disk_name}'], # JSON output (preferred) + ['smartctl', '-a', '-j', f'/dev/{disk_name}'], # JSON auto-detect (preferred) + ['smartctl', '-a', '-j', '-d', 'scsi', f'/dev/{disk_name}'], # JSON SCSI/SAS (early for SAS disks) ['smartctl', '-a', '-d', 'ata', f'/dev/{disk_name}'], # JSON with ATA device type ['smartctl', '-a', '-d', 'sat', f'/dev/{disk_name}'], # JSON with SAT device type ['smartctl', '-a', f'/dev/{disk_name}'], # Text output (fallback) @@ -2682,7 +2683,6 @@ def get_smart_data(disk_name): ['smartctl', '-i', '-H', '-A', f'/dev/{disk_name}'], # Info + Health + Attributes ['smartctl', '-i', '-H', '-A', '-d', 'ata', f'/dev/{disk_name}'], # With ATA ['smartctl', '-i', '-H', '-A', '-d', 'sat', f'/dev/{disk_name}'], # With SAT - ['smartctl', '-a', '-j', '-d', 'scsi', f'/dev/{disk_name}'], # JSON with SCSI device type ['smartctl', '-a', '-j', '-d', 'sat,12', f'/dev/{disk_name}'], # SAT with 12-byte commands ['smartctl', '-a', '-j', '-d', 'sat,16', f'/dev/{disk_name}'], # SAT with 16-byte commands ['smartctl', '-a', '-d', 'sat,12', f'/dev/{disk_name}'], # Text SAT with 12-byte commands @@ -2771,8 +2771,38 @@ def get_smart_data(disk_name): smart_data['total_lbas_written'] = round(total_gb, 2) + # Parse SCSI/SAS SMART data (no ATA attribute IDs) + device_protocol = data.get('device', {}).get('protocol', '') + if device_protocol == 'SCSI' or 'scsi_error_counter_log' in data: + # Temperature + if 'temperature' in data and 'current' in data['temperature']: + smart_data['temperature'] = data['temperature']['current'] + # Power-on hours + if 'power_on_time' in data: + smart_data['power_on_hours'] = data['power_on_time'].get('hours', 0) + # Power cycles from start-stop counter + scsi_ssc = data.get('scsi_start_stop_cycle_counter', {}) + if 'accumulated_start_stop_cycles' in scsi_ssc: + smart_data['power_cycles'] = scsi_ssc['accumulated_start_stop_cycles'] + # Grown defect list (equivalent to reallocated sectors) + gdl = data.get('scsi_grown_defect_list', 0) + if isinstance(gdl, dict): + gdl = gdl.get('count', 0) + smart_data['reallocated_sectors'] = gdl + # Read/write errors from error counter log + ecl = data.get('scsi_error_counter_log', {}) + read_errors = ecl.get('read', {}).get('errors_corrected_by_eccfast', 0) + \ + ecl.get('read', {}).get('errors_corrected_by_eccdelayed', 0) + \ + ecl.get('read', {}).get('total_errors_corrected', 0) + write_errors = ecl.get('write', {}).get('total_errors_corrected', 0) + # Uncorrected = potential data loss + uncorrected_read = ecl.get('read', {}).get('total_uncorrected_errors', 0) + uncorrected_write = ecl.get('write', {}).get('total_uncorrected_errors', 0) + smart_data['pending_sectors'] = uncorrected_read + uncorrected_write + # CRC errors not applicable for SAS, keep at 0 + # Parse ATA SMART attributes - if 'ata_smart_attributes' in data and 'table' in data['ata_smart_attributes']: + elif 'ata_smart_attributes' in data and 'table' in data['ata_smart_attributes']: for attr in data['ata_smart_attributes']['table']: attr_id = attr.get('id') @@ -6786,19 +6816,35 @@ def api_smart_status(disk_name): except (json.JSONDecodeError, ValueError): pass + # --- Detect device protocol (ATA vs SCSI/SAS) --- + device_protocol = data.get('device', {}).get('protocol', '') + is_scsi = device_protocol == 'SCSI' or 'scsi_error_counter_log' in data or 'scsi_grown_defect_list' in data + ata_data = data.get('ata_smart_data', {}) capabilities = ata_data.get('capabilities', {}) # --- Detect test in progress --- - self_test_block = ata_data.get('self_test', {}) - st_status = self_test_block.get('status', {}) - st_value = st_status.get('value', 0) - remaining_pct = st_status.get('remaining_percent') - # smartctl status value 241 (0xF1) = self-test in progress - if st_value == 241 or (remaining_pct is not None and 0 < remaining_pct <= 100): - result['status'] = 'running' - if remaining_pct is not None: - result['progress'] = 100 - remaining_pct + if is_scsi: + # SCSI: check background self-test status + scsi_selftest_status = data.get('scsi_self_test', {}).get('status', {}) + scsi_st_value = scsi_selftest_status.get('value', 0) + # value 15 = in progress on SCSI + if scsi_st_value == 15: + result['status'] = 'running' + remaining_pct = scsi_selftest_status.get('remaining_percent') + if remaining_pct is not None: + result['progress'] = 100 - remaining_pct + else: + self_test_block = ata_data.get('self_test', {}) + st_status = self_test_block.get('status', {}) + st_value = st_status.get('value', 0) + remaining_pct = st_status.get('remaining_percent') + # smartctl status value 241 (0xF1) = self-test in progress + if st_value == 241 or (remaining_pct is not None and 0 < remaining_pct <= 100): + result['status'] = 'running' + if remaining_pct is not None: + result['progress'] = 100 - remaining_pct + # Fallback text detection in case JSON misses it if result['status'] != 'running': try: @@ -6812,11 +6858,15 @@ def api_smart_status(disk_name): pass # --- Progress reporting capability --- - # Disks without self_test block (e.g. Phison/Kingston) cannot report test progress - has_self_test_block = 'self_test' in ata_data - supports_self_test = capabilities.get('self_tests_supported', False) or has_self_test_block - result['supports_progress_reporting'] = has_self_test_block - result['supports_self_test'] = supports_self_test + if is_scsi: + # SAS drives generally support self-test progress via SCSI log pages + result['supports_progress_reporting'] = True + result['supports_self_test'] = True + else: + has_self_test_block = 'self_test' in ata_data + supports_self_test = capabilities.get('self_tests_supported', False) or has_self_test_block + result['supports_progress_reporting'] = has_self_test_block + result['supports_self_test'] = supports_self_test # --- SMART health status --- if data.get('smart_status', {}).get('passed') is True: @@ -6833,29 +6883,67 @@ def api_smart_status(disk_name): trim_supported = data.get('trim', {}).get('supported', False) sata_version = data.get('sata_version', {}).get('string', '') interface_speed = data.get('interface_speed', {}).get('current', {}).get('string', '') + # SAS-specific: scsi_transport_protocol and logical_block_size + scsi_transport = data.get('scsi_transport_protocol', {}).get('name', '') + scsi_product = data.get('scsi_product', '') + scsi_vendor = data.get('scsi_vendor', '') + scsi_revision = data.get('scsi_revision', '') + logical_block_size = data.get('logical_block_size', 512) # --- Self-test polling times --- - polling_short = self_test_block.get('polling_minutes', {}).get('short') - polling_extended = self_test_block.get('polling_minutes', {}).get('extended') + if is_scsi: + polling_short = None + polling_extended = None + else: + self_test_block = ata_data.get('self_test', {}) + polling_short = self_test_block.get('polling_minutes', {}).get('short') + polling_extended = self_test_block.get('polling_minutes', {}).get('extended') # --- Error log count --- - error_log_count = data.get('ata_smart_error_log', {}).get('summary', {}).get('count', 0) + if is_scsi: + # For SCSI, sum uncorrected errors across read/write/verify + ecl = data.get('scsi_error_counter_log', {}) + error_log_count = ( + ecl.get('read', {}).get('total_uncorrected_errors', 0) + + ecl.get('write', {}).get('total_uncorrected_errors', 0) + + ecl.get('verify', {}).get('total_uncorrected_errors', 0) + ) + else: + error_log_count = data.get('ata_smart_error_log', {}).get('summary', {}).get('count', 0) # --- Self-test history --- - st_table = data.get('ata_smart_self_test_log', {}).get('standard', {}).get('table', []) self_test_history = [] - for entry in st_table: - type_str = entry.get('type', {}).get('string', 'Unknown') - t_norm = 'short' if 'Short' in type_str else 'long' if ('Extended' in type_str or 'Long' in type_str) else 'other' - st_entry = entry.get('status', {}) - passed_flag = st_entry.get('passed', True) - self_test_history.append({ - 'type': t_norm, - 'type_str': type_str, - 'status': 'passed' if passed_flag else 'failed', - 'status_str': st_entry.get('string', ''), - 'lifetime_hours': entry.get('lifetime_hours'), - }) + if is_scsi: + # SCSI self-test log format + scsi_st_table = data.get('scsi_self_test_log', {}).get('table', []) + for entry in scsi_st_table: + code = entry.get('code', {}) + type_str = code.get('string', 'Unknown') + t_norm = 'short' if 'Short' in type_str or 'short' in type_str else 'long' if ('Extended' in type_str or 'Long' in type_str or 'long' in type_str) else 'other' + result_val = entry.get('result', {}).get('value', 0) + passed_flag = result_val == 0 # 0 = completed without error + result_str = entry.get('result', {}).get('string', '') + self_test_history.append({ + 'type': t_norm, + 'type_str': type_str, + 'status': 'passed' if passed_flag else 'failed', + 'status_str': result_str, + 'lifetime_hours': entry.get('power_on_time', {}).get('hours'), + }) + else: + st_table = data.get('ata_smart_self_test_log', {}).get('standard', {}).get('table', []) + for entry in st_table: + type_str = entry.get('type', {}).get('string', 'Unknown') + t_norm = 'short' if 'Short' in type_str else 'long' if ('Extended' in type_str or 'Long' in type_str) else 'other' + st_entry = entry.get('status', {}) + passed_flag = st_entry.get('passed', True) + self_test_history.append({ + 'type': t_norm, + 'type_str': type_str, + 'status': 'passed' if passed_flag else 'failed', + 'status_str': st_entry.get('string', ''), + 'lifetime_hours': entry.get('lifetime_hours'), + }) if self_test_history: result['last_test'] = { @@ -6865,65 +6953,283 @@ def api_smart_status(disk_name): 'lifetime_hours': self_test_history[0]['lifetime_hours'] } - # --- Parse SMART attributes from JSON --- - ata_attrs = data.get('ata_smart_attributes', {}).get('table', []) + # --- Parse SMART attributes --- attrs = [] - for attr in ata_attrs: - attr_id = attr.get('id') - name = attr.get('name', '') - value = attr.get('value', 0) - worst = attr.get('worst', 0) - thresh = attr.get('thresh', 0) - raw_obj = attr.get('raw', {}) - raw_value = raw_obj.get('string', str(raw_obj.get('value', 0))) - flags = attr.get('flags', {}) - prefailure = flags.get('prefailure', False) - when_failed = attr.get('when_failed', '') - if when_failed == 'now': - status = 'critical' - elif prefailure and thresh > 0 and value <= thresh: - status = 'critical' - elif prefailure and thresh > 0: - # Proportional margin: smaller when thresh is close to 100 - # thresh=97 → margin 2, thresh=50 → margin 10, thresh=10 → margin 10 - warn_margin = min(10, max(2, (100 - thresh) // 3)) - status = 'warning' if value <= thresh + warn_margin else 'ok' - else: - status = 'ok' + if is_scsi: + # SCSI/SAS: Build virtual attributes from SCSI log pages + # These disks don't have ATA attribute IDs — we synthesize a table + ecl = data.get('scsi_error_counter_log', {}) + attr_idx = 1 + + # Grown Defect List (equivalent to Reallocated Sectors) + gdl = data.get('scsi_grown_defect_list', 0) + if isinstance(gdl, dict): + gdl = gdl.get('count', 0) + gdl_status = 'ok' if gdl == 0 else ('warning' if gdl < 50 else 'critical') + attrs.append({ + 'id': attr_idx, 'name': 'Grown Defect List', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': str(gdl), 'status': gdl_status + }) + attr_idx += 1 + + # Read Error Counters + read_log = ecl.get('read', {}) + read_corrected_fast = read_log.get('errors_corrected_by_eccfast', 0) + read_corrected_delayed = read_log.get('errors_corrected_by_eccdelayed', 0) + read_total_corrected = read_log.get('total_errors_corrected', 0) + read_uncorrected = read_log.get('total_uncorrected_errors', 0) + read_processed = read_log.get('gigabytes_processed', 0) attrs.append({ - 'id': attr_id, - 'name': name, - 'value': value, - 'worst': worst, - 'threshold': thresh, - 'raw_value': raw_value, - 'status': status, - 'prefailure': prefailure, - 'flags': flags.get('string', '').strip() + 'id': attr_idx, 'name': 'Read Errors Corrected', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{read_total_corrected:,}', 'status': 'ok' }) + attr_idx += 1 - # Fallback: if JSON gave no attributes, try text parser - if not attrs: - try: - aproc = subprocess.run(['smartctl', '-A', device], capture_output=True, text=True, timeout=10) - if aproc.returncode == 0: - attrs = _parse_smart_attributes(aproc.stdout.split('\n')) - except Exception: - pass + if read_corrected_fast or read_corrected_delayed: + attrs.append({ + 'id': attr_idx, 'name': 'Read ECC Fast', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{read_corrected_fast:,}', 'status': 'ok' + }) + attr_idx += 1 + attrs.append({ + 'id': attr_idx, 'name': 'Read ECC Delayed', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{read_corrected_delayed:,}', + 'status': 'ok' if read_corrected_delayed == 0 else 'warning' + }) + attr_idx += 1 + + read_unc_status = 'ok' if read_uncorrected == 0 else 'critical' + attrs.append({ + 'id': attr_idx, 'name': 'Read Uncorrected Errors', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': str(read_uncorrected), 'status': read_unc_status + }) + attr_idx += 1 + + if read_processed: + attrs.append({ + 'id': attr_idx, 'name': 'Read Data Processed', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{read_processed:,.2f} GB', 'status': 'ok' + }) + attr_idx += 1 + + # Write Error Counters + write_log = ecl.get('write', {}) + write_total_corrected = write_log.get('total_errors_corrected', 0) + write_uncorrected = write_log.get('total_uncorrected_errors', 0) + write_processed = write_log.get('gigabytes_processed', 0) + + attrs.append({ + 'id': attr_idx, 'name': 'Write Errors Corrected', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{write_total_corrected:,}', 'status': 'ok' + }) + attr_idx += 1 + + write_unc_status = 'ok' if write_uncorrected == 0 else 'critical' + attrs.append({ + 'id': attr_idx, 'name': 'Write Uncorrected Errors', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': str(write_uncorrected), 'status': write_unc_status + }) + attr_idx += 1 + + if write_processed: + attrs.append({ + 'id': attr_idx, 'name': 'Write Data Processed', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{write_processed:,.2f} GB', 'status': 'ok' + }) + attr_idx += 1 + + # Verify Error Counters (background verify operations) + verify_log = ecl.get('verify', {}) + if verify_log: + verify_total_corrected = verify_log.get('total_errors_corrected', 0) + verify_uncorrected = verify_log.get('total_uncorrected_errors', 0) + + attrs.append({ + 'id': attr_idx, 'name': 'Verify Errors Corrected', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{verify_total_corrected:,}', 'status': 'ok' + }) + attr_idx += 1 + + verify_unc_status = 'ok' if verify_uncorrected == 0 else 'critical' + attrs.append({ + 'id': attr_idx, 'name': 'Verify Uncorrected Errors', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': str(verify_uncorrected), 'status': verify_unc_status + }) + attr_idx += 1 + + # Non-medium errors (controller/bus errors, not media-related) + non_medium = data.get('scsi_error_counter_log', {}).get('non_medium_error', {}).get('count') + if non_medium is not None: + nm_status = 'ok' if non_medium < 100 else 'warning' + attrs.append({ + 'id': attr_idx, 'name': 'Non-Medium Errors', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{non_medium:,}', 'status': nm_status + }) + attr_idx += 1 + + # Temperature + temp_val = data.get('temperature', {}).get('current', 0) + if temp_val > 0: + temp_status = 'ok' if temp_val <= 55 else ('warning' if temp_val <= 65 else 'critical') + attrs.append({ + 'id': attr_idx, 'name': 'Temperature', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{temp_val}°C', 'status': temp_status + }) + attr_idx += 1 + + # Power-On Hours + poh_val = data.get('power_on_time', {}).get('hours', 0) + if poh_val > 0: + attrs.append({ + 'id': attr_idx, 'name': 'Power On Hours', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{poh_val:,}', 'status': 'ok' + }) + attr_idx += 1 + + # Start-Stop Cycle Counter + scsi_ssc = data.get('scsi_start_stop_cycle_counter', {}) + acc_cycles = scsi_ssc.get('accumulated_start_stop_cycles') + spec_cycles = scsi_ssc.get('specified_cycle_count_over_device_lifetime') + if acc_cycles is not None: + cycle_status = 'ok' + if spec_cycles and spec_cycles > 0: + usage_ratio = acc_cycles / spec_cycles + cycle_status = 'ok' if usage_ratio < 0.8 else ('warning' if usage_ratio < 0.95 else 'critical') + attrs.append({ + 'id': attr_idx, 'name': 'Start-Stop Cycles', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{acc_cycles:,}' + (f' / {spec_cycles:,}' if spec_cycles else ''), + 'status': cycle_status + }) + attr_idx += 1 + + # Load-Unload Cycle Counter (for SAS HDDs) + acc_load = scsi_ssc.get('accumulated_load_unload_cycles') + spec_load = scsi_ssc.get('specified_load_unload_count_over_device_lifetime') + if acc_load is not None: + load_status = 'ok' + if spec_load and spec_load > 0: + load_ratio = acc_load / spec_load + load_status = 'ok' if load_ratio < 0.8 else ('warning' if load_ratio < 0.95 else 'critical') + attrs.append({ + 'id': attr_idx, 'name': 'Load-Unload Cycles', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{acc_load:,}' + (f' / {spec_load:,}' if spec_load else ''), + 'status': load_status + }) + attr_idx += 1 + + # Background scan results + bg_scan = data.get('scsi_background_scan_results', {}) + if bg_scan: + bg_status_val = bg_scan.get('status', {}).get('value', 0) + bg_scan_progress = bg_scan.get('status', {}).get('string', '') + scan_errors = bg_scan.get('number_of_background_medium_scans_performed', 0) + bg_media_errors = bg_scan.get('number_of_background_scans_performed', 0) + + if 'scan_errors' in bg_scan or bg_status_val > 0: + attrs.append({ + 'id': attr_idx, 'name': 'Background Scan Status', + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': bg_scan_progress or 'OK', + 'status': 'ok' if bg_status_val == 0 else 'warning' + }) + attr_idx += 1 + + # SAS PHY log (link errors) + sas_phy = data.get('sas_phy_event_counter_log', []) + if sas_phy: + for phy_entry in sas_phy[:1]: # First PHY only + events = phy_entry.get('phy_event_counters', []) + for ev in events: + ev_name = ev.get('name', '') + ev_value = ev.get('value', 0) + if ev_value > 0 and ('error' in ev_name.lower() or 'invalid' in ev_name.lower()): + attrs.append({ + 'id': attr_idx, 'name': ev_name[:40], + 'value': '-', 'worst': '-', 'threshold': '-', + 'raw_value': f'{ev_value:,}', + 'status': 'warning' if ev_value < 100 else 'critical' + }) + attr_idx += 1 + + else: + # ATA: Parse SMART attributes from JSON + ata_attrs = data.get('ata_smart_attributes', {}).get('table', []) + for attr in ata_attrs: + attr_id = attr.get('id') + name = attr.get('name', '') + value = attr.get('value', 0) + worst = attr.get('worst', 0) + thresh = attr.get('thresh', 0) + raw_obj = attr.get('raw', {}) + raw_value = raw_obj.get('string', str(raw_obj.get('value', 0))) + flags = attr.get('flags', {}) + prefailure = flags.get('prefailure', False) + when_failed = attr.get('when_failed', '') + + if when_failed == 'now': + status = 'critical' + elif prefailure and thresh > 0 and value <= thresh: + status = 'critical' + elif prefailure and thresh > 0: + # Proportional margin: smaller when thresh is close to 100 + # thresh=97 → margin 2, thresh=50 → margin 10, thresh=10 → margin 10 + warn_margin = min(10, max(2, (100 - thresh) // 3)) + status = 'warning' if value <= thresh + warn_margin else 'ok' + else: + status = 'ok' + + attrs.append({ + 'id': attr_id, + 'name': name, + 'value': value, + 'worst': worst, + 'threshold': thresh, + 'raw_value': raw_value, + 'status': status, + 'prefailure': prefailure, + 'flags': flags.get('string', '').strip() + }) + + # Fallback: if JSON gave no attributes, try text parser + if not attrs: + try: + aproc = subprocess.run(['smartctl', '-A', device], capture_output=True, text=True, timeout=10) + if aproc.returncode == 0: + attrs = _parse_smart_attributes(aproc.stdout.split('\n')) + except Exception: + pass # --- Build enriched smart_data --- temp = data.get('temperature', {}).get('current', 0) poh = data.get('power_on_time', {}).get('hours', 0) cycles = data.get('power_cycle_count', 0) + if cycles == 0 and is_scsi: + cycles = data.get('scsi_start_stop_cycle_counter', {}).get('accumulated_start_stop_cycles', 0) result['smart_data'] = { 'device': disk_name, - 'model': data.get('model_name', 'Unknown'), + 'model': data.get('model_name', '') or (f'{scsi_vendor} {scsi_product}'.strip() if is_scsi else 'Unknown'), 'model_family': model_family, 'serial': data.get('serial_number', 'Unknown'), - 'firmware': data.get('firmware_version', 'Unknown'), + 'firmware': data.get('firmware_version', '') or scsi_revision or 'Unknown', 'smart_status': result.get('smart_status', 'unknown'), 'temperature': temp, 'power_on_hours': poh, @@ -6932,14 +7238,16 @@ def api_smart_status(disk_name): 'form_factor': form_factor, 'physical_block_size': physical_block_size, 'trim_supported': trim_supported, - 'sata_version': sata_version, - 'interface_speed': interface_speed, + 'sata_version': sata_version if not is_scsi else '', + 'interface_speed': interface_speed or (scsi_transport if is_scsi else ''), 'polling_minutes_short': polling_short, 'polling_minutes_extended': polling_extended, - 'supports_progress_reporting': has_self_test_block, + 'supports_progress_reporting': result.get('supports_progress_reporting', False), 'error_log_count': error_log_count, 'self_test_history': self_test_history, - 'attributes': attrs + 'attributes': attrs, + 'is_sas': is_scsi, + 'logical_block_size': logical_block_size if is_scsi else None, } return jsonify(result)