diff --git a/AppImage/app/globals.css b/AppImage/app/globals.css index 27a00f2c..50b26d5c 100644 --- a/AppImage/app/globals.css +++ b/AppImage/app/globals.css @@ -163,3 +163,15 @@ .xterm-rows { margin: 0 !important; } + +/* ===================== */ +/* Progress Animations */ +/* ===================== */ +@keyframes indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 15ecd0b0..1374b7fd 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -1436,12 +1436,20 @@ export function StorageOverview() { - {/* SMART Test Data Section (from real test JSON) */} + {/* SMART Test Data Section (from real test JSON) - Uniform style with Wear & Lifetime */} {(loadingSmartJson || smartJsonData?.has_data) && (

- SMART Test Data + {(() => { + // Check if this is SSD without Proxmox wear data - show as "Wear & Lifetime" + const isNvme = selectedDisk.name?.includes('nvme') + const hasProxmoxWear = getWearIndicator(selectedDisk) !== null + if (!isNvme && !hasProxmoxWear && smartJsonData?.has_data) { + return 'Wear & Lifetime' + } + return 'SMART Test Data' + })()} {smartJsonData?.has_data && ( Real Test @@ -1455,33 +1463,18 @@ export function StorageOverview() {

) : smartJsonData?.has_data && smartJsonData.data ? (
- {/* Last Test Info */} -
-
-

Last Test Date

-

- {smartJsonData.timestamp - ? new Date(smartJsonData.timestamp).toLocaleString() - : 'Unknown'} -

-
-
-

Test Type

-

{smartJsonData.test_type || 'Unknown'}

-
-
- - {/* SSD Life Estimation from JSON (if available) */} + {/* SSD/NVMe Life Estimation from JSON - Uniform style */} {(() => { const data = smartJsonData.data as Record const ataAttrs = data?.ata_smart_attributes as { table?: Array<{ id: number; name: string; value: number; raw?: { value: number } }> } const table = ataAttrs?.table || [] - // Look for wear-related attributes + // Look for wear-related attributes for SSD const wearAttr = table.find(a => a.name?.toLowerCase().includes('wear_leveling') || a.name?.toLowerCase().includes('media_wearout') || a.name?.toLowerCase().includes('percent_lifetime') || + a.name?.toLowerCase().includes('ssd_life_left') || a.id === 177 || a.id === 231 || a.id === 233 ) @@ -1491,55 +1484,95 @@ export function StorageOverview() { a.id === 241 ) - if (wearAttr || lbasAttr) { - return ( -
-

From Real SMART Test

-
- {wearAttr && ( -
-

{wearAttr.name?.replace(/_/g, ' ')}

-

- {wearAttr.value}% -

-
- )} - {lbasAttr && lbasAttr.raw?.value && ( -
-

Total Data Written

-

- {(() => { - const tbWritten = (lbasAttr.raw.value * 512) / (1024 ** 4) - return tbWritten >= 1 - ? `${tbWritten.toFixed(2)} TB` - : `${(tbWritten * 1024).toFixed(2)} GB` - })()} -

-
- )} -
-
- ) - } + // Look for power on hours from SMART data + const pohAttr = table.find(a => + a.name?.toLowerCase().includes('power_on_hours') || + a.id === 9 + ) // For NVMe, check nvme_smart_health_information_log const nvmeHealth = data?.nvme_smart_health_information_log as Record - if (nvmeHealth) { - const percentUsed = nvmeHealth.percentage_used as number - const dataUnitsWritten = nvmeHealth.data_units_written as number - const availableSpare = nvmeHealth.available_spare as number - + + // Calculate data written + let dataWrittenTB = 0 + let dataWrittenLabel = '' + if (lbasAttr && lbasAttr.raw?.value) { + dataWrittenTB = (lbasAttr.raw.value * 512) / (1024 ** 4) + dataWrittenLabel = dataWrittenTB >= 1 + ? `${dataWrittenTB.toFixed(2)} TB` + : `${(dataWrittenTB * 1024).toFixed(2)} GB` + } else if (nvmeHealth?.data_units_written) { + const units = nvmeHealth.data_units_written as number + dataWrittenTB = (units * 512000) / (1024 ** 4) + dataWrittenLabel = dataWrittenTB >= 1 + ? `${dataWrittenTB.toFixed(2)} TB` + : `${(dataWrittenTB * 1024).toFixed(2)} GB` + } + + // Get wear percentage + let wearPercent: number | null = null + let wearLabel = 'Life Remaining' + if (wearAttr) { + wearPercent = wearAttr.value + wearLabel = wearAttr.name?.replace(/_/g, ' ') || 'Life Remaining' + } else if (nvmeHealth?.percentage_used !== undefined) { + wearPercent = 100 - (nvmeHealth.percentage_used as number) + wearLabel = 'Life Remaining' + } + + // Calculate estimated life remaining + let estimatedLife = '' + const powerOnHours = pohAttr?.raw?.value || selectedDisk.power_on_hours || 0 + if (wearPercent !== null && wearPercent > 0 && wearPercent < 100 && powerOnHours > 0) { + const usedPercent = 100 - wearPercent + if (usedPercent > 0) { + const totalEstimatedHours = powerOnHours / (usedPercent / 100) + const remainingHours = totalEstimatedHours - powerOnHours + const remainingYears = remainingHours / (24 * 365) + if (remainingYears >= 1) { + estimatedLife = `~${remainingYears.toFixed(1)} years` + } else { + const remainingMonths = remainingYears * 12 + estimatedLife = `~${remainingMonths.toFixed(0)} months` + } + } + } + + // Available spare for NVMe + const availableSpare = nvmeHealth?.available_spare as number | undefined + + if (wearPercent !== null || dataWrittenLabel) { return ( -
-

From Real SMART Test (NVMe)

-
- {percentUsed !== undefined && ( -
-

Percent Used

-

80 ? 'text-red-400' : percentUsed > 50 ? 'text-yellow-400' : 'text-green-400'}`}> - {percentUsed}% + <> + {/* Wear Progress Bar - Same style as NVMe */} + {wearPercent !== null && ( +

+
+

{wearLabel}

+

+ {wearPercent}%

+ div]:bg-red-500' : wearPercent < 50 ? '[&>div]:bg-yellow-500' : '[&>div]:bg-green-500'}`} + /> +
+ )} + + {/* Stats Grid - Same layout as NVMe Wear & Lifetime */} +
+ {estimatedLife && ( +
+

Estimated Life Remaining

+

{estimatedLife}

+
+ )} + {dataWrittenLabel && ( +
+

Total Data Written

+

{dataWrittenLabel}

+
)} {availableSpare !== undefined && (
@@ -1549,30 +1582,28 @@ export function StorageOverview() {

)} - {dataUnitsWritten !== undefined && ( -
-

Total Data Written

-

- {(() => { - const tbWritten = (dataUnitsWritten * 512000) / (1024 ** 4) - return tbWritten >= 1 - ? `${tbWritten.toFixed(2)} TB` - : `${(tbWritten * 1024).toFixed(2)} GB` - })()} -

-
- )}
-
+ ) } - return null })()} -

- Run a SMART test in the SMART Test tab for more detailed analysis. -

+ {/* Last Test Info */} +
+
+

Last Test Date

+

+ {smartJsonData.timestamp + ? new Date(smartJsonData.timestamp).toLocaleString() + : 'Unknown'} +

+
+
+

Test Type

+

{smartJsonData.test_type || 'Unknown'}

+
+
) : (
@@ -1700,30 +1731,92 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri // Build attributes table - format differs for NVMe vs SATA const isNvmeForTable = diskType === 'NVMe' + + // Explanations for NVMe metrics + const nvmeExplanations: Record = { + 'Critical Warning': 'Active alert flags from the NVMe controller. Any non-zero value requires immediate investigation.', + 'Temperature': 'Composite temperature. Sustained high temps cause throttling and reduce lifespan.', + 'Available Spare': 'Spare NAND blocks remaining. Alert triggers below 5%.', + 'Available Spare Threshold': 'Threshold below which spare blocks are considered critical.', + 'Percentage Used': "Drive's own estimate of endurance consumed. 100% means rated lifespan has been reached.", + 'Percent Used': "Drive's own estimate of endurance consumed. 100% means rated lifespan has been reached.", + 'Media Errors': 'Unrecoverable errors involving the NAND flash. Any non-zero value indicates flash cell damage.', + 'Unsafe Shutdowns': 'Power losses without proper shutdown. Very high counts can cause firmware corruption.', + '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.', + } + + // Explanations for SATA/SSD attributes + const sataExplanations: Record = { + 'Raw Read Error Rate': 'Raw read errors detected. High values on Seagate drives are often normal (uses proprietary formula).', + '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.', + '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.', + '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.', + } + + const getAttrExplanation = (name: string, isNvme: boolean): string => { + const cleanName = name.replace(/_/g, ' ') + if (isNvme) { + return nvmeExplanations[cleanName] || nvmeExplanations[name] || '' + } + return sataExplanations[cleanName] || sataExplanations[name] || '' + } + 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 + // NVMe format: Metric | Value | Status (with explanation) return ` - ${attr.name} - ${attr.value} - ${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()} + +
${attr.name}
+ ${explanation ? `
${explanation}
` : ''} + + ${attr.value} + ${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()} ` } else { - // SATA format: ID | Attribute | Val | Worst | Thr | Raw | Status + // SATA format: ID | Attribute | Val | Worst | Thr | Raw | Status (with explanation) return ` - ${attr.id} - ${attr.name.replace(/_/g, ' ')} - ${attr.value} - ${attr.worst} - ${attr.threshold} - ${attr.raw_value} - ${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()} + ${attr.id} + +
${attr.name.replace(/_/g, ' ')}
+ ${explanation ? `
${explanation}
` : ''} + + ${attr.value} + ${attr.worst} + ${attr.threshold} + ${attr.raw_value} + ${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()} ` } @@ -2585,19 +2678,36 @@ function SmartTestTab({ disk, observations = [] }: SmartTestTabProps) { fetchSmartStatus() // Poll for status updates + // For disks that don't report progress, we keep polling but show an indeterminate progress bar + let pollCount = 0 + const maxPolls = testType === 'short' ? 36 : 720 // 3 min for short, 1 hour for long (at 5s intervals) + const pollInterval = setInterval(async () => { + pollCount++ try { - const data = await fetchApi(`/api/storage/smart/${disk.name}`) - setTestStatus(data) - if (data.status !== 'running') { + const statusData = await fetchApi(`/api/storage/smart/${disk.name}`) + setTestStatus(statusData) + + // Only clear runningTest when we get a definitive "not running" status + if (statusData.status !== 'running') { clearInterval(pollInterval) setRunningTest(null) + // Refresh SMART JSON data to get new test results + fetchSmartStatus() } } catch { + // Don't clear on error - keep showing progress + } + + // Safety timeout: stop polling after max duration + if (pollCount >= maxPolls) { clearInterval(pollInterval) setRunningTest(null) + // Refresh status one more time to get final result + fetchSmartStatus() } }, 5000) + } catch (err) { const message = err instanceof Error ? err.message : 'Failed to start test' setTestError(message) @@ -2666,79 +2776,80 @@ function SmartTestTab({ disk, observations = [] }: SmartTestTabProps) { Run SMART Test +
- - - + + +

Short test takes ~2 minutes. Extended test runs in the background and can take several hours for large disks. You will receive a notification when the test completes.

- {/* Error Message */} - {testError && ( -
- -
-

Failed to start test

-

{testError}

-
-
- )} - -
+ {/* Error Message */} + {testError && ( +
+ +
+

Failed to start test

+

{testError}

+
+
+ )} +
- {/* Test Progress */} - {testStatus.status === 'running' && ( + {/* Test Progress - Show when API reports running OR when we just started a test */} + {(testStatus.status === 'running' || runningTest !== null) && (
-
+
-
+

- {testStatus.test_type === 'short' ? 'Short' : 'Extended'} test in progress + {(runningTest || testStatus.test_type) === 'short' ? 'Short' : 'Extended'} test in progress

Please wait while the test completes...

+ {/* Only show progress bar if the disk reports progress percentage */} {testStatus.progress !== undefined && ( - + )}
)}