update storage-overview.tsx

This commit is contained in:
MacRimi
2026-04-16 17:36:23 +02:00
parent 4611be734f
commit cbebd5147c
2 changed files with 664 additions and 181 deletions

View File

@@ -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<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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 `
<tr>
<td class="col-name">
<div style="font-weight:500;">${attr.name}</div>
${explanation ? `<div style="font-size:10px;color:#64748b;margin-top:2px;">${explanation}</div>` : ''}
</td>
<td style="text-align:center;font-family:monospace;vertical-align:top;padding-top:12px;">${attr.value}</td>
<td style="text-align:center;font-family:monospace;vertical-align:top;padding-top:12px;">${displayValue}</td>
<td style="vertical-align:top;padding-top:8px;"><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()}</span></td>
</tr>
`
@@ -1967,7 +2112,7 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
</td>
<td style="text-align:center;vertical-align:top;padding-top:12px;">${attr.value}</td>
<td style="text-align:center;vertical-align:top;padding-top:12px;">${attr.worst}</td>
<td class="hide-mobile" style="text-align:center;vertical-align:top;padding-top:12px;">${attr.threshold}</td>
<td style="text-align:center;vertical-align:top;padding-top:12px;">${attr.threshold}</td>
<td class="col-raw" style="vertical-align:top;padding-top:12px;">${attr.raw_value}</td>
<td style="vertical-align:top;padding-top:8px;"><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()}</span></td>
</tr>
@@ -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. */
</style>
</head>
<body>
<script>
function pmxPrint(){
try { window.print(); }
catch(e) {
var isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
var el = document.getElementById('pmx-print-hint');
if(el) el.textContent = isMac ? 'Use Cmd+P to save as PDF' : 'Use Ctrl+P to save as PDF';
}
}
</script>
<!-- Top bar (screen only) -->
<div class="top-bar no-print">
<div class="top-bar-left">
<div class="top-bar-title">SMART Health Report</div>
<div class="top-bar-subtitle">/dev/${disk.name}</div>
<div style="display:flex;align-items:center;gap:12px;">
<strong>SMART Health Report</strong>
<span id="pmx-print-hint" style="font-size:11px;opacity:0.7;">/dev/${disk.name}</span>
</div>
<button onclick="window.print()">Print Report</button>
<button onclick="pmxPrint()">Print / Save as PDF</button>
</div>
<!-- Header -->
@@ -2463,15 +2635,16 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
</div>
<div class="card">
<div class="card-label">Type</div>
<div class="card-value" style="font-size:11px;">${diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}</div>
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (disk.rotation_rate ? `SAS ${disk.rotation_rate} RPM` : 'SAS SSD') : diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}</div>
</div>
</div>
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? `
<div class="grid-4" style="margin-top:8px;">
${modelFamily ? `<div class="card"><div class="card-label">Family</div><div class="card-value" style="font-size:11px;">${modelFamily}</div></div>` : ''}
${formFactor ? `<div class="card"><div class="card-label">Form Factor</div><div class="card-value" style="font-size:11px;">${formFactor}</div></div>` : ''}
${sataVersion ? `<div class="card"><div class="card-label">Interface</div><div class="card-value" style="font-size:11px;">${sataVersion}${ifaceSpeed ? ` · ${ifaceSpeed}` : ''}</div></div>` : (ifaceSpeed ? `<div class="card"><div class="card-label">Link Speed</div><div class="card-value" style="font-size:11px;">${ifaceSpeed}</div></div>` : '')}
${!isNvmeDisk ? `<div class="card"><div class="card-label">TRIM</div><div class="card-value" style="font-size:11px;color:${trimSupported ? '#16a34a' : '#94a3b8'};">${trimSupported ? 'Supported' : 'Not supported'}${physBlockSize === 4096 ? ' · 4K AF' : ''}</div></div>` : ''}
${sataVersion ? `<div class="card"><div class="card-label">Interface</div><div class="card-value" style="font-size:11px;">${sataVersion}${ifaceSpeed ? ` · ${ifaceSpeed}` : ''}</div></div>` : (ifaceSpeed ? `<div class="card"><div class="card-label">${isSasDisk ? 'Transport' : 'Link Speed'}</div><div class="card-value" style="font-size:11px;">${ifaceSpeed}</div></div>` : '')}
${!isNvmeDisk && !isSasDisk ? `<div class="card"><div class="card-label">TRIM</div><div class="card-value" style="font-size:11px;color:${trimSupported ? '#16a34a' : '#94a3b8'};">${trimSupported ? 'Supported' : 'Not supported'}${physBlockSize === 4096 ? ' · 4K AF' : ''}</div></div>` : ''}
${isSasDisk && sd?.logical_block_size ? `<div class="card"><div class="card-label">Block Size</div><div class="card-value" style="font-size:11px;">${sd.logical_block_size} bytes</div></div>` : ''}
</div>
` : ''}
<div class="grid-4">
@@ -2498,15 +2671,15 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
<div class="grid-3" style="margin-top:8px;">
<div class="card card-c">
<div class="card-value" style="color:${(disk.pending_sectors ?? 0) > 0 ? '#dc2626' : '#16a34a'}">${disk.pending_sectors ?? 0}</div>
<div class="card-label">Pending Sectors</div>
<div class="card-label">${isSasDisk ? 'Uncorrected Errors' : 'Pending Sectors'}</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:${(disk.crc_errors ?? 0) > 0 ? '#ca8a04' : '#16a34a'}">${disk.crc_errors ?? 0}</div>
<div class="card-value" style="color:${isSasDisk ? '#94a3b8' : (disk.crc_errors ?? 0) > 0 ? '#ca8a04' : '#16a34a'}">${isSasDisk ? 'N/A' : (disk.crc_errors ?? 0)}</div>
<div class="card-label">CRC Errors</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:${(disk.reallocated_sectors ?? 0) > 0 ? '#dc2626' : '#16a34a'}">${disk.reallocated_sectors ?? 0}</div>
<div class="card-label">Reallocated Sectors</div>
<div class="card-label">${isSasDisk ? 'Grown Defects' : 'Reallocated Sectors'}</div>
</div>
</div>
` : ''}
@@ -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')}. SMART Attributes / NVMe Health Metrics -->
<!-- SMART Attributes / NVMe Health Metrics / SAS Error Counters -->
<div class="section">
<div class="section-title">${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)` : ''})</div>
<div class="section-title">${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)` : ''})</div>
<table class="attr-tbl">
<thead>
<tr>
${isNvmeDisk ? '' : '<th style="width:28px;">ID</th>'}
<th class="col-name">${isNvmeDisk ? 'Metric' : 'Attribute'}</th>
<th style="text-align:center;width:${isNvmeDisk ? '80px' : '40px'};">Value</th>
${isNvmeDisk ? '' : '<th style="text-align:center;width:40px;">Worst</th>'}
${isNvmeDisk ? '' : '<th class="hide-mobile" style="text-align:center;width:40px;">Thr</th>'}
${isNvmeDisk ? '' : '<th class="col-raw" style="width:60px;">Raw</th>'}
${useSimpleTable ? '' : '<th style="width:28px;">ID</th>'}
<th class="col-name">${isNvmeDisk ? 'Metric' : isSasDisk ? 'Metric' : 'Attribute'}</th>
<th style="text-align:center;width:${useSimpleTable ? '80px' : '40px'};">Value</th>
${useSimpleTable ? '' : '<th style="text-align:center;width:40px;">Worst</th>'}
${useSimpleTable ? '' : '<th style="text-align:center;width:40px;">Thr</th>'}
${useSimpleTable ? '' : '<th class="col-raw" style="width:60px;">Raw</th>'}
<th style="width:36px;"></th>
</tr>
</thead>
<tbody>
${attributeRows || '<tr><td colspan="' + (isNvmeDisk ? '3' : '7') + '" style="text-align:center;color:#64748b;padding:20px;">No ' + (isNvmeDisk ? 'NVMe metrics' : 'SMART attributes') + ' available</td></tr>'}
${attributeRows || '<tr><td colspan="' + (useSimpleTable ? '3' : '7') + '" style="text-align:center;color:#64748b;padding:20px;">No ' + (isNvmeDisk ? 'NVMe metrics' : isSasDisk ? 'SAS metrics' : 'SMART attributes') + ' available</td></tr>'}
</tbody>
</table>
</div>
@@ -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
<div className="space-y-3">
<h4 className="font-semibold flex items-center gap-2">
<Activity className="h-4 w-4" />
{isNvme ? 'NVMe Health Metrics' : 'SMART Attributes'}
{isNvme ? 'NVMe Health Metrics' : testStatus.smart_data?.is_sas ? 'SAS/SCSI Health Metrics' : 'SMART Attributes'}
</h4>
<div className="border rounded-lg overflow-hidden">
<div className={`grid ${isNvme ? 'grid-cols-10' : 'grid-cols-12'} gap-2 p-3 bg-muted/30 text-xs font-medium text-muted-foreground`}>
{!isNvme && <div className="col-span-1">ID</div>}
<div className={isNvme ? 'col-span-5' : 'col-span-5'}>Attribute</div>
<div className={isNvme ? 'col-span-3 text-center' : 'col-span-2 text-center'}>Value</div>
{!isNvme && <div className="col-span-2 text-center">Worst</div>}
<div className={`grid ${(isNvme || testStatus.smart_data?.is_sas) ? 'grid-cols-10' : 'grid-cols-12'} gap-2 p-3 bg-muted/30 text-xs font-medium text-muted-foreground`}>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-1">ID</div>}
<div className={(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-5' : 'col-span-5'}>Attribute</div>
<div className={(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-3 text-center' : 'col-span-2 text-center'}>Value</div>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-2 text-center">Worst</div>}
<div className="col-span-2 text-center">Status</div>
</div>
<div className="divide-y divide-border max-h-[200px] overflow-y-auto">
{testStatus.smart_data.attributes.slice(0, 15).map((attr) => (
<div key={attr.id} className={`grid ${isNvme ? 'grid-cols-10' : 'grid-cols-12'} gap-2 p-3 text-sm items-center`}>
{!isNvme && <div className="col-span-1 text-muted-foreground">{attr.id}</div>}
<div className={`${isNvme ? 'col-span-5' : 'col-span-5'} truncate`} title={attr.name}>{attr.name}</div>
<div className={`${isNvme ? 'col-span-3' : 'col-span-2'} text-center font-mono`}>{attr.value}</div>
{!isNvme && <div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</div>}
<div key={attr.id} className={`grid ${(isNvme || testStatus.smart_data?.is_sas) ? 'grid-cols-10' : 'grid-cols-12'} gap-2 p-3 text-sm items-center`}>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-1 text-muted-foreground">{attr.id}</div>}
<div className={`${(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-5' : 'col-span-5'} truncate`} title={attr.name}>{attr.name}</div>
<div className={`${(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-3' : 'col-span-2'} text-center font-mono`}>{testStatus.smart_data?.is_sas ? attr.raw_value : attr.value}</div>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</div>}
<div className="col-span-2 text-center">
{attr.status === 'ok' ? (
<CheckCircle2 className="h-4 w-4 text-green-500 mx-auto" />

View File

@@ -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)