mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-25 08:56:21 +00:00
update storage-overview.tsx
This commit is contained in:
@@ -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"
|
let diskType = "HDD"
|
||||||
if (disk.name.startsWith("nvme")) {
|
if (disk.name.startsWith("nvme")) {
|
||||||
diskType = "NVMe"
|
diskType = "NVMe"
|
||||||
|
} else if (isSasDisk) {
|
||||||
|
diskType = "SAS"
|
||||||
} else if (!disk.rotation_rate || disk.rotation_rate === 0) {
|
} else if (!disk.rotation_rate || disk.rotation_rate === 0) {
|
||||||
diskType = "SSD"
|
diskType = "SSD"
|
||||||
}
|
}
|
||||||
@@ -1888,71 +1891,213 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
// Explanations for NVMe metrics
|
// Explanations for NVMe metrics
|
||||||
const nvmeExplanations: Record<string, string> = {
|
const nvmeExplanations: Record<string, string> = {
|
||||||
'Critical Warning': 'Active alert flags from the NVMe controller. Any non-zero value requires immediate investigation.',
|
'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.',
|
'Temperature': 'Composite temperature reported by the controller. Sustained high temps cause thermal throttling and reduce NAND lifespan.',
|
||||||
'Available Spare': 'Spare NAND blocks remaining. Alert triggers below 5%.',
|
'Temperature Sensor 1': 'Primary temperature sensor, usually the NAND flash. Most representative of flash health.',
|
||||||
'Available Spare Threshold': 'Threshold below which spare blocks are considered critical.',
|
'Temperature Sensor 2': 'Secondary sensor, often the controller die. Typically runs hotter than Sensor 1.',
|
||||||
'Percentage Used': "Drive's own estimate of endurance consumed. 100% means rated lifespan has been reached.",
|
'Temperature Sensor 3': 'Tertiary sensor, if present. Location varies by manufacturer.',
|
||||||
'Percent Used': "Drive's own estimate of endurance consumed. 100% means rated lifespan has been reached.",
|
'Available Spare': 'Percentage of spare NAND blocks remaining for bad-block replacement. Alert triggers below threshold.',
|
||||||
'Media Errors': 'Unrecoverable errors involving the NAND flash. Any non-zero value indicates flash cell damage.',
|
'Available Spare Threshold': 'Manufacturer-set minimum for Available Spare. Below this, the drive flags a critical warning.',
|
||||||
'Unsafe Shutdowns': 'Power losses without proper shutdown. Very high counts can cause firmware corruption.',
|
'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 Cycles': 'Total on/off cycles. Frequent cycling increases connector and capacitor wear.',
|
||||||
'Power On Hours': 'Total hours the drive has been powered on.',
|
'Power On Hours': 'Total cumulative hours the drive has been powered on since manufacture.',
|
||||||
'Data Units Read': 'Total data read from the drive in 512KB units.',
|
'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 to the drive in 512KB units.',
|
'Data Units Written': 'Total data written in 512KB units. Compare with TBW rating to estimate remaining endurance.',
|
||||||
'Host Read Commands': 'Total read commands processed by the controller.',
|
'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 processed by the controller.',
|
'Host Write Commands': 'Total write commands issued by the host. Includes filesystem metadata writes.',
|
||||||
'Controller Busy Time': 'Minutes the controller was busy processing commands.',
|
'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 log. Often includes benign self-test artifacts.',
|
'Error Log Entries': 'Number of entries in the error information log. Often includes benign self-test artifacts; cross-check with Media Errors.',
|
||||||
'Warning Temp Time': 'Minutes spent in the warning temperature range. Zero is ideal.',
|
'Error Information Log Entries': 'Number of entries in the error information log. Often includes benign self-test artifacts.',
|
||||||
'Critical Temp Time': 'Minutes spent in the critical temperature range. Should always be zero.',
|
'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> = {
|
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 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.',
|
'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).',
|
'Retired Block Count': 'NAND blocks retired due to wear or failure (SSD). Similar to Reallocated Sector Count for HDDs.',
|
||||||
'Start Stop Count': 'Number of spindle start/stop cycles (HDD only).',
|
'Reallocated Event Count': 'Number of remap operations performed. Each event means a bad sector was replaced.',
|
||||||
'Power On Hours': 'Total hours the drive has been powered on.',
|
'Current Pending Sector': 'Unstable sectors waiting to be remapped on next write. May resolve or become permanently reallocated.',
|
||||||
'Power Cycle Count': 'Total number of complete power on/off cycles.',
|
'Current Pending Sector Count': 'Unstable sectors waiting to be remapped on next write. Non-zero warrants monitoring.',
|
||||||
'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.',
|
|
||||||
'Pending Sectors': 'Sectors waiting to be remapped. May resolve or become reallocated.',
|
'Pending Sectors': 'Sectors waiting to be remapped. May resolve or become reallocated.',
|
||||||
'Offline Uncorrectable': 'Uncorrectable errors found during offline scan. Indicates potential data loss.',
|
'Offline Uncorrectable': 'Sectors that failed during offline scan and could not be corrected. Indicates potential data loss.',
|
||||||
'UDMA CRC Error Count': 'Interface communication errors. Usually caused by cable or connection issues.',
|
'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.',
|
'CRC Errors': 'Interface communication errors. Usually caused by cable or connection issues.',
|
||||||
'Wear Leveling Count': 'SSD wear indicator. Lower values mean more wear.',
|
'CRC Error Count': 'Data transfer checksum errors. Replace the SATA cable if this value grows.',
|
||||||
'Media Wearout Indicator': 'SSD life remaining estimate. Lower values mean less life remaining.',
|
'Command Timeout': 'Commands that took too long and timed out. May indicate controller or connection issues.',
|
||||||
'Total LBAs Written': 'Total logical blocks written to the drive.',
|
'Interface CRC Error Count': 'CRC errors on the interface link. Cable or connector problem.',
|
||||||
'Total LBAs Read': 'Total logical blocks read from the drive.',
|
|
||||||
'SSD Life Left': 'Estimated remaining lifespan percentage.',
|
// === ECC & Data Integrity ===
|
||||||
'Percent Lifetime Remain': 'Estimated remaining lifespan percentage.',
|
'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, ' ')
|
const cleanName = name.replace(/_/g, ' ')
|
||||||
if (isNvme) {
|
if (diskKind === 'NVMe') {
|
||||||
return nvmeExplanations[cleanName] || nvmeExplanations[name] || ''
|
return nvmeExplanations[cleanName] || nvmeExplanations[name] || ''
|
||||||
}
|
}
|
||||||
|
if (diskKind === 'SAS') {
|
||||||
|
return sasExplanations[cleanName] || sasExplanations[name] || ''
|
||||||
|
}
|
||||||
return sataExplanations[cleanName] || sataExplanations[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 attributeRows = smartAttributes.map((attr, i) => {
|
||||||
const statusColor = attr.status === 'ok' ? '#16a34a' : attr.status === 'warning' ? '#ca8a04' : '#dc2626'
|
const statusColor = attr.status === 'ok' ? '#16a34a' : attr.status === 'warning' ? '#ca8a04' : '#dc2626'
|
||||||
const statusBg = attr.status === 'ok' ? '#16a34a15' : attr.status === 'warning' ? '#ca8a0415' : '#dc262615'
|
const statusBg = attr.status === 'ok' ? '#16a34a15' : attr.status === 'warning' ? '#ca8a0415' : '#dc262615'
|
||||||
const explanation = getAttrExplanation(attr.name, isNvmeForTable)
|
const explanation = getAttrExplanation(attr.name, diskType)
|
||||||
|
|
||||||
if (isNvmeForTable) {
|
if (useSimpleTable) {
|
||||||
// NVMe format: Metric | Value | Status (with explanation)
|
// NVMe/SAS format: Metric | Value | Status (with explanation)
|
||||||
|
const displayValue = isSasDisk ? attr.raw_value : attr.value
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="col-name">
|
<td class="col-name">
|
||||||
<div style="font-weight:500;">${attr.name}</div>
|
<div style="font-weight:500;">${attr.name}</div>
|
||||||
${explanation ? `<div style="font-size:10px;color:#64748b;margin-top:2px;">${explanation}</div>` : ''}
|
${explanation ? `<div style="font-size:10px;color:#64748b;margin-top:2px;">${explanation}</div>` : ''}
|
||||||
</td>
|
</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>
|
<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>
|
</tr>
|
||||||
`
|
`
|
||||||
@@ -1967,7 +2112,7 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
</td>
|
</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.value}</td>
|
||||||
<td style="text-align:center;vertical-align:top;padding-top:12px;">${attr.worst}</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 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>
|
<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>
|
</tr>
|
||||||
@@ -1993,6 +2138,11 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
if (temp <= 59) return '#16a34a'
|
if (temp <= 59) return '#16a34a'
|
||||||
if (temp <= 70) return '#ca8a04'
|
if (temp <= 70) return '#ca8a04'
|
||||||
return '#dc2626'
|
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':
|
case 'HDD':
|
||||||
default:
|
default:
|
||||||
// HDD: <=45 green, 46-55 yellow, >55 red
|
// HDD: <=45 green, 46-55 yellow, >55 red
|
||||||
@@ -2003,12 +2153,14 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Temperature thresholds for display
|
// Temperature thresholds for display
|
||||||
const tempThresholds = diskType === 'NVMe'
|
const tempThresholds = diskType === 'NVMe'
|
||||||
? { optimal: '<=70°C', warning: '71-80°C', critical: '>80°C' }
|
? { optimal: '<=70°C', warning: '71-80°C', critical: '>80°C' }
|
||||||
: diskType === 'SSD'
|
: diskType === 'SSD'
|
||||||
? { optimal: '<=59°C', warning: '60-70°C', critical: '>70°C' }
|
? { 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' }
|
: { optimal: '<=45°C', warning: '46-55°C', critical: '>55°C' }
|
||||||
|
|
||||||
const isNvmeDisk = diskType === 'NVMe'
|
const isNvmeDisk = diskType === 'NVMe'
|
||||||
|
|
||||||
// NVMe Wear & Lifetime data
|
// 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; }
|
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; }
|
@page { margin: 10mm; size: A4; }
|
||||||
@media print {
|
@media print {
|
||||||
|
html, body { margin: 0 !important; padding: 0 !important; }
|
||||||
.no-print { display: none !important; }
|
.no-print { display: none !important; }
|
||||||
.page-break { page-break-before: always; }
|
.page-break { page-break-before: always; }
|
||||||
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
* { -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; }
|
.section { page-break-inside: avoid; break-inside: avoid; }
|
||||||
.grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr !important; }
|
.exec-box { page-break-inside: avoid; break-inside: avoid; }
|
||||||
.grid-3 { grid-template-columns: 1fr 1fr 1fr !important; }
|
.card { page-break-inside: avoid; break-inside: avoid; }
|
||||||
.grid-2 { grid-template-columns: 1fr 1fr !important; }
|
.grid-2, .grid-3, .grid-4 { page-break-inside: avoid; break-inside: avoid; }
|
||||||
.rpt-header { flex-direction: row !important; }
|
.section-title { page-break-after: avoid; break-after: avoid; }
|
||||||
.hide-mobile { display: table-cell !important; }
|
.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 {
|
@media screen {
|
||||||
body { max-width: 1000px; margin: 0 auto; padding: 24px 32px; padding-top: 64px; overflow-x: hidden; }
|
body { max-width: 1000px; margin: 0 auto; padding: 24px 32px; padding-top: 64px; overflow-x: hidden; }
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 640px) {
|
@media print { .top-bar { display: none; } body { padding-top: 0; } }
|
||||||
body { padding: 16px; padding-top: 64px; }
|
|
||||||
.grid-4 { grid-template-columns: 1fr 1fr; }
|
/* Top bar for screen only */
|
||||||
.rpt-header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
|
||||||
.rpt-header-right { text-align: left; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Top bar */
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
position: fixed; top: 0; left: 0; right: 0; background: #0f172a; color: #e2e8f0;
|
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-left { display: flex; align-items: center; gap: 12px; }
|
||||||
.top-bar-title { font-weight: 600; }
|
.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;
|
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||||
}
|
}
|
||||||
.top-bar button:hover { background: #0891b2; }
|
.top-bar button:hover { background: #0891b2; }
|
||||||
@media print { .top-bar { display: none; } body { padding-top: 0; } }
|
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.rpt-header {
|
.rpt-header {
|
||||||
@@ -2279,7 +2445,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
.exec-box {
|
.exec-box {
|
||||||
display: flex; align-items: flex-start; gap: 20px; padding: 20px;
|
display: flex; align-items: flex-start; gap: 20px; padding: 20px;
|
||||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 16px;
|
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 16px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.health-ring {
|
.health-ring {
|
||||||
width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column;
|
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 tr:hover { background: #f8fafc; }
|
||||||
.attr-tbl .col-name { word-break: break-word; }
|
.attr-tbl .col-name { word-break: break-word; }
|
||||||
.attr-tbl .col-raw { font-family: monospace; font-size: 10px; }
|
.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 */
|
/* Recommendations */
|
||||||
.rec-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px; border-radius: 6px; margin-bottom: 8px; }
|
.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;
|
margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0;
|
||||||
display: flex; justify-content: space-between; font-size: 10px; color: #94a3b8;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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) -->
|
<!-- Top bar (screen only) -->
|
||||||
<div class="top-bar no-print">
|
<div class="top-bar no-print">
|
||||||
<div class="top-bar-left">
|
<div style="display:flex;align-items:center;gap:12px;">
|
||||||
<div class="top-bar-title">SMART Health Report</div>
|
<strong>SMART Health Report</strong>
|
||||||
<div class="top-bar-subtitle">/dev/${disk.name}</div>
|
<span id="pmx-print-hint" style="font-size:11px;opacity:0.7;">/dev/${disk.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="window.print()">Print Report</button>
|
<button onclick="pmxPrint()">Print / Save as PDF</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -2463,15 +2635,16 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Type</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? `
|
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? `
|
||||||
<div class="grid-4" style="margin-top:8px;">
|
<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>` : ''}
|
${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>` : ''}
|
${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>` : '')}
|
${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 ? `<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>` : ''}
|
${!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>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="grid-4">
|
<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="grid-3" style="margin-top:8px;">
|
||||||
<div class="card card-c">
|
<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-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>
|
||||||
<div class="card card-c">
|
<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 class="card-label">CRC Errors</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-c">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
@@ -2722,23 +2895,23 @@ ${!isNvmeDisk && diskType === 'SSD' ? (() => {
|
|||||||
return ''
|
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">
|
||||||
<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">
|
<table class="attr-tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
${isNvmeDisk ? '' : '<th style="width:28px;">ID</th>'}
|
${useSimpleTable ? '' : '<th style="width:28px;">ID</th>'}
|
||||||
<th class="col-name">${isNvmeDisk ? 'Metric' : 'Attribute'}</th>
|
<th class="col-name">${isNvmeDisk ? 'Metric' : isSasDisk ? 'Metric' : 'Attribute'}</th>
|
||||||
<th style="text-align:center;width:${isNvmeDisk ? '80px' : '40px'};">Value</th>
|
<th style="text-align:center;width:${useSimpleTable ? '80px' : '40px'};">Value</th>
|
||||||
${isNvmeDisk ? '' : '<th style="text-align:center;width:40px;">Worst</th>'}
|
${useSimpleTable ? '' : '<th style="text-align:center;width:40px;">Worst</th>'}
|
||||||
${isNvmeDisk ? '' : '<th class="hide-mobile" style="text-align:center;width:40px;">Thr</th>'}
|
${useSimpleTable ? '' : '<th style="text-align:center;width:40px;">Thr</th>'}
|
||||||
${isNvmeDisk ? '' : '<th class="col-raw" style="width:60px;">Raw</th>'}
|
${useSimpleTable ? '' : '<th class="col-raw" style="width:60px;">Raw</th>'}
|
||||||
<th style="width:36px;"></th>
|
<th style="width:36px;"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -2938,6 +3111,8 @@ interface SmartTestStatus {
|
|||||||
self_test_history?: SmartSelfTestEntry[]
|
self_test_history?: SmartSelfTestEntry[]
|
||||||
attributes: SmartAttribute[]
|
attributes: SmartAttribute[]
|
||||||
nvme_raw?: NvmeRaw
|
nvme_raw?: NvmeRaw
|
||||||
|
is_sas?: boolean
|
||||||
|
logical_block_size?: number
|
||||||
}
|
}
|
||||||
tools_installed?: {
|
tools_installed?: {
|
||||||
smartctl: boolean
|
smartctl: boolean
|
||||||
@@ -3261,23 +3436,23 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-semibold flex items-center gap-2">
|
<h4 className="font-semibold flex items-center gap-2">
|
||||||
<Activity className="h-4 w-4" />
|
<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>
|
</h4>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<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`}>
|
<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 && <div className="col-span-1">ID</div>}
|
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-1">ID</div>}
|
||||||
<div className={isNvme ? 'col-span-5' : 'col-span-5'}>Attribute</div>
|
<div className={(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-5' : 'col-span-5'}>Attribute</div>
|
||||||
<div className={isNvme ? 'col-span-3 text-center' : 'col-span-2 text-center'}>Value</div>
|
<div className={(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-3 text-center' : 'col-span-2 text-center'}>Value</div>
|
||||||
{!isNvme && <div className="col-span-2 text-center">Worst</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 className="col-span-2 text-center">Status</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border max-h-[200px] overflow-y-auto">
|
<div className="divide-y divide-border max-h-[200px] overflow-y-auto">
|
||||||
{testStatus.smart_data.attributes.slice(0, 15).map((attr) => (
|
{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`}>
|
<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 && <div className="col-span-1 text-muted-foreground">{attr.id}</div>}
|
{!isNvme && !testStatus.smart_data?.is_sas && <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 || testStatus.smart_data?.is_sas) ? '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>
|
<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 && <div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</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">
|
<div className="col-span-2 text-center">
|
||||||
{attr.status === 'ok' ? (
|
{attr.status === 'ok' ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500 mx-auto" />
|
<CheckCircle2 className="h-4 w-4 text-green-500 mx-auto" />
|
||||||
|
|||||||
@@ -2673,7 +2673,8 @@ def get_smart_data(disk_name):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
commands_to_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', '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', '-d', 'sat', f'/dev/{disk_name}'], # JSON with SAT device type
|
||||||
['smartctl', '-a', f'/dev/{disk_name}'], # Text output (fallback)
|
['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', f'/dev/{disk_name}'], # Info + Health + Attributes
|
||||||
['smartctl', '-i', '-H', '-A', '-d', 'ata', f'/dev/{disk_name}'], # With ATA
|
['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', '-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,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', '-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
|
['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)
|
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
|
# 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']:
|
for attr in data['ata_smart_attributes']['table']:
|
||||||
attr_id = attr.get('id')
|
attr_id = attr.get('id')
|
||||||
@@ -6786,19 +6816,35 @@ def api_smart_status(disk_name):
|
|||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
pass
|
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', {})
|
ata_data = data.get('ata_smart_data', {})
|
||||||
capabilities = ata_data.get('capabilities', {})
|
capabilities = ata_data.get('capabilities', {})
|
||||||
|
|
||||||
# --- Detect test in progress ---
|
# --- Detect test in progress ---
|
||||||
self_test_block = ata_data.get('self_test', {})
|
if is_scsi:
|
||||||
st_status = self_test_block.get('status', {})
|
# SCSI: check background self-test status
|
||||||
st_value = st_status.get('value', 0)
|
scsi_selftest_status = data.get('scsi_self_test', {}).get('status', {})
|
||||||
remaining_pct = st_status.get('remaining_percent')
|
scsi_st_value = scsi_selftest_status.get('value', 0)
|
||||||
# smartctl status value 241 (0xF1) = self-test in progress
|
# value 15 = in progress on SCSI
|
||||||
if st_value == 241 or (remaining_pct is not None and 0 < remaining_pct <= 100):
|
if scsi_st_value == 15:
|
||||||
result['status'] = 'running'
|
result['status'] = 'running'
|
||||||
if remaining_pct is not None:
|
remaining_pct = scsi_selftest_status.get('remaining_percent')
|
||||||
result['progress'] = 100 - remaining_pct
|
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
|
# Fallback text detection in case JSON misses it
|
||||||
if result['status'] != 'running':
|
if result['status'] != 'running':
|
||||||
try:
|
try:
|
||||||
@@ -6812,11 +6858,15 @@ def api_smart_status(disk_name):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# --- Progress reporting capability ---
|
# --- Progress reporting capability ---
|
||||||
# Disks without self_test block (e.g. Phison/Kingston) cannot report test progress
|
if is_scsi:
|
||||||
has_self_test_block = 'self_test' in ata_data
|
# SAS drives generally support self-test progress via SCSI log pages
|
||||||
supports_self_test = capabilities.get('self_tests_supported', False) or has_self_test_block
|
result['supports_progress_reporting'] = True
|
||||||
result['supports_progress_reporting'] = has_self_test_block
|
result['supports_self_test'] = True
|
||||||
result['supports_self_test'] = supports_self_test
|
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 ---
|
# --- SMART health status ---
|
||||||
if data.get('smart_status', {}).get('passed') is True:
|
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)
|
trim_supported = data.get('trim', {}).get('supported', False)
|
||||||
sata_version = data.get('sata_version', {}).get('string', '')
|
sata_version = data.get('sata_version', {}).get('string', '')
|
||||||
interface_speed = data.get('interface_speed', {}).get('current', {}).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 ---
|
# --- Self-test polling times ---
|
||||||
polling_short = self_test_block.get('polling_minutes', {}).get('short')
|
if is_scsi:
|
||||||
polling_extended = self_test_block.get('polling_minutes', {}).get('extended')
|
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 ---
|
||||||
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 ---
|
# --- Self-test history ---
|
||||||
st_table = data.get('ata_smart_self_test_log', {}).get('standard', {}).get('table', [])
|
|
||||||
self_test_history = []
|
self_test_history = []
|
||||||
for entry in st_table:
|
if is_scsi:
|
||||||
type_str = entry.get('type', {}).get('string', 'Unknown')
|
# SCSI self-test log format
|
||||||
t_norm = 'short' if 'Short' in type_str else 'long' if ('Extended' in type_str or 'Long' in type_str) else 'other'
|
scsi_st_table = data.get('scsi_self_test_log', {}).get('table', [])
|
||||||
st_entry = entry.get('status', {})
|
for entry in scsi_st_table:
|
||||||
passed_flag = st_entry.get('passed', True)
|
code = entry.get('code', {})
|
||||||
self_test_history.append({
|
type_str = code.get('string', 'Unknown')
|
||||||
'type': t_norm,
|
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'
|
||||||
'type_str': type_str,
|
result_val = entry.get('result', {}).get('value', 0)
|
||||||
'status': 'passed' if passed_flag else 'failed',
|
passed_flag = result_val == 0 # 0 = completed without error
|
||||||
'status_str': st_entry.get('string', ''),
|
result_str = entry.get('result', {}).get('string', '')
|
||||||
'lifetime_hours': entry.get('lifetime_hours'),
|
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:
|
if self_test_history:
|
||||||
result['last_test'] = {
|
result['last_test'] = {
|
||||||
@@ -6865,65 +6953,283 @@ def api_smart_status(disk_name):
|
|||||||
'lifetime_hours': self_test_history[0]['lifetime_hours']
|
'lifetime_hours': self_test_history[0]['lifetime_hours']
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Parse SMART attributes from JSON ---
|
# --- Parse SMART attributes ---
|
||||||
ata_attrs = data.get('ata_smart_attributes', {}).get('table', [])
|
|
||||||
attrs = []
|
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':
|
if is_scsi:
|
||||||
status = 'critical'
|
# SCSI/SAS: Build virtual attributes from SCSI log pages
|
||||||
elif prefailure and thresh > 0 and value <= thresh:
|
# These disks don't have ATA attribute IDs — we synthesize a table
|
||||||
status = 'critical'
|
ecl = data.get('scsi_error_counter_log', {})
|
||||||
elif prefailure and thresh > 0:
|
attr_idx = 1
|
||||||
# Proportional margin: smaller when thresh is close to 100
|
|
||||||
# thresh=97 → margin 2, thresh=50 → margin 10, thresh=10 → margin 10
|
# Grown Defect List (equivalent to Reallocated Sectors)
|
||||||
warn_margin = min(10, max(2, (100 - thresh) // 3))
|
gdl = data.get('scsi_grown_defect_list', 0)
|
||||||
status = 'warning' if value <= thresh + warn_margin else 'ok'
|
if isinstance(gdl, dict):
|
||||||
else:
|
gdl = gdl.get('count', 0)
|
||||||
status = 'ok'
|
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({
|
attrs.append({
|
||||||
'id': attr_id,
|
'id': attr_idx, 'name': 'Read Errors Corrected',
|
||||||
'name': name,
|
'value': '-', 'worst': '-', 'threshold': '-',
|
||||||
'value': value,
|
'raw_value': f'{read_total_corrected:,}', 'status': 'ok'
|
||||||
'worst': worst,
|
|
||||||
'threshold': thresh,
|
|
||||||
'raw_value': raw_value,
|
|
||||||
'status': status,
|
|
||||||
'prefailure': prefailure,
|
|
||||||
'flags': flags.get('string', '').strip()
|
|
||||||
})
|
})
|
||||||
|
attr_idx += 1
|
||||||
|
|
||||||
# Fallback: if JSON gave no attributes, try text parser
|
if read_corrected_fast or read_corrected_delayed:
|
||||||
if not attrs:
|
attrs.append({
|
||||||
try:
|
'id': attr_idx, 'name': 'Read ECC Fast',
|
||||||
aproc = subprocess.run(['smartctl', '-A', device], capture_output=True, text=True, timeout=10)
|
'value': '-', 'worst': '-', 'threshold': '-',
|
||||||
if aproc.returncode == 0:
|
'raw_value': f'{read_corrected_fast:,}', 'status': 'ok'
|
||||||
attrs = _parse_smart_attributes(aproc.stdout.split('\n'))
|
})
|
||||||
except Exception:
|
attr_idx += 1
|
||||||
pass
|
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 ---
|
# --- Build enriched smart_data ---
|
||||||
temp = data.get('temperature', {}).get('current', 0)
|
temp = data.get('temperature', {}).get('current', 0)
|
||||||
poh = data.get('power_on_time', {}).get('hours', 0)
|
poh = data.get('power_on_time', {}).get('hours', 0)
|
||||||
cycles = data.get('power_cycle_count', 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'] = {
|
result['smart_data'] = {
|
||||||
'device': disk_name,
|
'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,
|
'model_family': model_family,
|
||||||
'serial': data.get('serial_number', 'Unknown'),
|
'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'),
|
'smart_status': result.get('smart_status', 'unknown'),
|
||||||
'temperature': temp,
|
'temperature': temp,
|
||||||
'power_on_hours': poh,
|
'power_on_hours': poh,
|
||||||
@@ -6932,14 +7238,16 @@ def api_smart_status(disk_name):
|
|||||||
'form_factor': form_factor,
|
'form_factor': form_factor,
|
||||||
'physical_block_size': physical_block_size,
|
'physical_block_size': physical_block_size,
|
||||||
'trim_supported': trim_supported,
|
'trim_supported': trim_supported,
|
||||||
'sata_version': sata_version,
|
'sata_version': sata_version if not is_scsi else '',
|
||||||
'interface_speed': interface_speed,
|
'interface_speed': interface_speed or (scsi_transport if is_scsi else ''),
|
||||||
'polling_minutes_short': polling_short,
|
'polling_minutes_short': polling_short,
|
||||||
'polling_minutes_extended': polling_extended,
|
'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,
|
'error_log_count': error_log_count,
|
||||||
'self_test_history': self_test_history,
|
'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)
|
return jsonify(result)
|
||||||
|
|||||||
Reference in New Issue
Block a user