diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index aa261bac..c117b4d5 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -1346,16 +1346,16 @@ export function StorageOverview() { - {/* Wear & Lifetime — DiskInfo (real-time, refreshed every 60s) is the primary source. - JSON from SMART test only supplements with fields DiskInfo doesn't have (available_spare). */} + {/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */} {(() => { - // --- Step 1: DiskInfo = primary source (always fresh) --- let wearUsed: number | null = null let lifeRemaining: number | null = null let estimatedLife = '' let dataWritten = '' let spare: number | undefined + // --- Step 1: DiskInfo = primary source (refreshed every 60s, always fresh) --- + // Works for NVMe (percentage_used) and SSD (media_wearout_indicator, ssd_life_left) const wi = getWearIndicator(selectedDisk) if (wi) { wearUsed = wi.value @@ -1367,7 +1367,7 @@ export function StorageOverview() { } } - // --- Step 2: Supplement with SMART test JSON for extra fields only --- + // --- Step 2: SMART test JSON — primary for SSD, supplement for NVMe --- if (smartJsonData?.has_data && smartJsonData.data) { const data = smartJsonData.data as Record const nvmeHealth = (data?.nvme_smart_health_information_log || data) as Record @@ -3296,6 +3296,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) { const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) const [deleting, setDeleting] = useState(null) + const [viewingReport, setViewingReport] = useState(null) const fetchHistory = async () => { try { @@ -3317,37 +3318,79 @@ function HistoryTab({ disk }: { disk: DiskInfo }) { await fetchApi(`/api/storage/smart/${disk.name}/history/${filename}`, { method: 'DELETE' }) setHistory(prev => prev.filter(h => h.filename !== filename)) } catch { - // Silently fail — entry stays in list + // Silently fail } finally { setDeleting(null) } } - const handleDownload = (filename: string) => { - const baseUrl = window.location.origin - const token = document.cookie.split(';').find(c => c.trim().startsWith('auth_token='))?.split('=')?.[1] - const url = `${baseUrl}/api/storage/smart/${disk.name}/history/${filename}${token ? `?token=${token}` : ''}` - const a = document.createElement('a') - a.href = url - a.download = `${disk.name}_${filename}` - a.click() + const handleDownload = async (filename: string) => { + try { + const response = await fetchApi>(`/api/storage/smart/${disk.name}/history/${filename}`) + const blob = new Blob([JSON.stringify(response, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${disk.name}_${filename}` + a.click() + URL.revokeObjectURL(url) + } catch { + // Silently fail + } } const handleViewReport = async (entry: SmartHistoryEntry) => { try { - const data = await fetchApi<{ has_data: boolean; data?: Record }>( - `/api/storage/smart/${disk.name}/latest` - ) - if (data.has_data && data.data) { - // Use the openSmartReport function — it needs testStatus with smart_data - // For now we open the JSON in a new tab as formatted view - const blob = new Blob([JSON.stringify(data.data, null, 2)], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - window.open(url, '_blank') + setViewingReport(entry.filename) + const jsonData = await fetchApi>(`/api/storage/smart/${disk.name}/history/${entry.filename}`) + + // Build attributes from JSON for openSmartReport + const isNvme = disk.name.includes('nvme') + let attrs: SmartAttribute[] = [] + + if (isNvme) { + // NVMe: build from nvme smart-log fields + const fieldMap: [string, string][] = [ + ['critical_warning', 'Critical Warning'], ['temperature', 'Temperature'], + ['avail_spare', 'Available Spare'], ['percent_used', 'Percentage Used'], + ['data_units_written', 'Data Units Written'], ['data_units_read', 'Data Units Read'], + ['power_cycles', 'Power Cycles'], ['power_on_hours', 'Power On Hours'], + ['unsafe_shutdowns', 'Unsafe Shutdowns'], ['media_errors', 'Media Errors'], + ['num_err_log_entries', 'Error Log Entries'], + ] + fieldMap.forEach(([key, name], i) => { + if (jsonData[key] !== undefined) { + const v = jsonData[key] as number + const status = (key === 'critical_warning' || key === 'media_errors') && v > 0 ? 'critical' as const : 'ok' as const + attrs.push({ id: i + 1, name, value: String(v), worst: '-', threshold: '-', raw_value: String(v), status }) + } + }) + } else { + // SATA: parse from ata_smart_attributes + const ataTable = (jsonData as Record)?.ata_smart_attributes as { table?: Array> } + if (ataTable?.table) { + attrs = ataTable.table.map(a => ({ + id: (a.id as number) || 0, + name: (a.name as string) || '', + value: (a.value as number) || 0, + worst: (a.worst as number) || 0, + threshold: (a.thresh as number) || 0, + raw_value: (a.raw as Record)?.string as string || String((a.raw as Record)?.value || 0), + status: 'ok' as const + })) + } } + + const testStatus: SmartTestStatus = { + status: 'idle', + smart_data: { device: disk.name, model: disk.model || '', serial: disk.serial || '', firmware: '', smart_status: 'passed', temperature: disk.temperature, power_on_hours: disk.power_on_hours || 0, attributes: attrs } + } + + openSmartReport(disk, testStatus, attrs, [], entry.timestamp) } catch { - // Fallback: download the file - handleDownload(entry.filename) + // Silently fail + } finally { + setViewingReport(null) } } @@ -3363,7 +3406,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) { if (history.length === 0) { return (
- +

No test history

Run a SMART test to start building history for this disk.

@@ -3390,15 +3433,15 @@ function HistoryTab({ disk }: { disk: DiskInfo }) { const testDate = new Date(entry.timestamp) const ageDays = Math.floor((Date.now() - testDate.getTime()) / (1000 * 60 * 60 * 24)) const isDeleting = deleting === entry.filename + const isViewing = viewingReport === entry.filename return (
- {/* Test type badge */} - {/* Date and info */}

{testDate.toLocaleString()} - {isLatest && ( - latest - )} + {isLatest && latest}

{ageDays === 0 ? 'Today' : ageDays === 1 ? 'Yesterday' : `${ageDays} days ago`}

- {/* Action buttons */}
+