Update storage-overview.tsx

This commit is contained in:
MacRimi
2026-04-16 12:08:36 +02:00
parent b341ba8297
commit 324cb23f75

View File

@@ -1346,16 +1346,16 @@ export function StorageOverview() {
</div> </div>
</div> </div>
{/* Wear & Lifetime — DiskInfo (real-time, refreshed every 60s) is the primary source. {/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */}
JSON from SMART test only supplements with fields DiskInfo doesn't have (available_spare). */}
{(() => { {(() => {
// --- Step 1: DiskInfo = primary source (always fresh) ---
let wearUsed: number | null = null let wearUsed: number | null = null
let lifeRemaining: number | null = null let lifeRemaining: number | null = null
let estimatedLife = '' let estimatedLife = ''
let dataWritten = '' let dataWritten = ''
let spare: number | undefined 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) const wi = getWearIndicator(selectedDisk)
if (wi) { if (wi) {
wearUsed = wi.value 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) { if (smartJsonData?.has_data && smartJsonData.data) {
const data = smartJsonData.data as Record<string, unknown> const data = smartJsonData.data as Record<string, unknown>
const nvmeHealth = (data?.nvme_smart_health_information_log || data) as Record<string, unknown> const nvmeHealth = (data?.nvme_smart_health_information_log || data) as Record<string, unknown>
@@ -3296,6 +3296,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
const [history, setHistory] = useState<SmartHistoryEntry[]>([]) const [history, setHistory] = useState<SmartHistoryEntry[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [deleting, setDeleting] = useState<string | null>(null) const [deleting, setDeleting] = useState<string | null>(null)
const [viewingReport, setViewingReport] = useState<string | null>(null)
const fetchHistory = async () => { const fetchHistory = async () => {
try { try {
@@ -3317,37 +3318,79 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
await fetchApi(`/api/storage/smart/${disk.name}/history/${filename}`, { method: 'DELETE' }) await fetchApi(`/api/storage/smart/${disk.name}/history/${filename}`, { method: 'DELETE' })
setHistory(prev => prev.filter(h => h.filename !== filename)) setHistory(prev => prev.filter(h => h.filename !== filename))
} catch { } catch {
// Silently fail — entry stays in list // Silently fail
} finally { } finally {
setDeleting(null) setDeleting(null)
} }
} }
const handleDownload = (filename: string) => { const handleDownload = async (filename: string) => {
const baseUrl = window.location.origin try {
const token = document.cookie.split(';').find(c => c.trim().startsWith('auth_token='))?.split('=')?.[1] const response = await fetchApi<Record<string, unknown>>(`/api/storage/smart/${disk.name}/history/${filename}`)
const url = `${baseUrl}/api/storage/smart/${disk.name}/history/${filename}${token ? `?token=${token}` : ''}` const blob = new Blob([JSON.stringify(response, null, 2)], { type: 'application/json' })
const a = document.createElement('a') const url = URL.createObjectURL(blob)
a.href = url const a = document.createElement('a')
a.download = `${disk.name}_${filename}` a.href = url
a.click() a.download = `${disk.name}_${filename}`
a.click()
URL.revokeObjectURL(url)
} catch {
// Silently fail
}
} }
const handleViewReport = async (entry: SmartHistoryEntry) => { const handleViewReport = async (entry: SmartHistoryEntry) => {
try { try {
const data = await fetchApi<{ has_data: boolean; data?: Record<string, unknown> }>( setViewingReport(entry.filename)
`/api/storage/smart/${disk.name}/latest` const jsonData = await fetchApi<Record<string, unknown>>(`/api/storage/smart/${disk.name}/history/${entry.filename}`)
)
if (data.has_data && data.data) { // Build attributes from JSON for openSmartReport
// Use the openSmartReport function — it needs testStatus with smart_data const isNvme = disk.name.includes('nvme')
// For now we open the JSON in a new tab as formatted view let attrs: SmartAttribute[] = []
const blob = new Blob([JSON.stringify(data.data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob) if (isNvme) {
window.open(url, '_blank') // 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<string, unknown>)?.ata_smart_attributes as { table?: Array<Record<string, unknown>> }
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, unknown>)?.string as string || String((a.raw as Record<string, unknown>)?.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 { } catch {
// Fallback: download the file // Silently fail
handleDownload(entry.filename) } finally {
setViewingReport(null)
} }
} }
@@ -3363,7 +3406,7 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
if (history.length === 0) { if (history.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center py-12 gap-3 text-center"> <div className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Archive className="h-10 w-10 text-muted-foreground/50" /> <Archive className="h-10 w-10 text-muted-foreground/30" />
<div> <div>
<p className="text-sm font-medium">No test history</p> <p className="text-sm font-medium">No test history</p>
<p className="text-xs text-muted-foreground mt-1">Run a SMART test to start building history for this disk.</p> <p className="text-xs text-muted-foreground mt-1">Run a SMART test to start building history for this disk.</p>
@@ -3390,15 +3433,15 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
const testDate = new Date(entry.timestamp) const testDate = new Date(entry.timestamp)
const ageDays = Math.floor((Date.now() - testDate.getTime()) / (1000 * 60 * 60 * 24)) const ageDays = Math.floor((Date.now() - testDate.getTime()) / (1000 * 60 * 60 * 24))
const isDeleting = deleting === entry.filename const isDeleting = deleting === entry.filename
const isViewing = viewingReport === entry.filename
return ( return (
<div <div
key={entry.filename} key={entry.filename}
className={`border rounded-lg p-3 flex items-center gap-3 transition-colors ${ className={`border rounded-lg p-3 flex items-center gap-3 transition-colors ${
isLatest ? 'border-orange-500/30 bg-orange-500/5' : 'border-border' isLatest ? 'border-orange-500/30' : 'border-border'
} ${isDeleting ? 'opacity-50' : ''}`} } ${isDeleting ? 'opacity-50' : ''}`}
> >
{/* Test type badge */}
<Badge className={`text-[10px] px-1.5 flex-shrink-0 ${ <Badge className={`text-[10px] px-1.5 flex-shrink-0 ${
entry.test_type === 'long' entry.test_type === 'long'
? 'bg-orange-500/10 text-orange-400 border-orange-500/20' ? 'bg-orange-500/10 text-orange-400 border-orange-500/20'
@@ -3407,24 +3450,28 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
{entry.test_type === 'long' ? 'Extended' : 'Short'} {entry.test_type === 'long' ? 'Extended' : 'Short'}
</Badge> </Badge>
{/* Date and info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate"> <p className="text-sm font-medium truncate">
{testDate.toLocaleString()} {testDate.toLocaleString()}
{isLatest && ( {isLatest && <span className="text-[10px] text-orange-400 ml-2">latest</span>}
<span className="text-[10px] text-orange-400 ml-2">latest</span>
)}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{ageDays === 0 ? 'Today' : ageDays === 1 ? 'Yesterday' : `${ageDays} days ago`} {ageDays === 0 ? 'Today' : ageDays === 1 ? 'Yesterday' : `${ageDays} days ago`}
</p> </p>
</div> </div>
{/* Action buttons */}
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
<Button <Button
variant="ghost" variant="ghost" size="sm"
size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-green-400"
onClick={() => handleViewReport(entry)}
disabled={isViewing}
title="View Report"
>
{isViewing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost" size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-blue-400" className="h-7 w-7 p-0 text-muted-foreground hover:text-blue-400"
onClick={() => handleDownload(entry.filename)} onClick={() => handleDownload(entry.filename)}
title="Download JSON" title="Download JSON"
@@ -3432,18 +3479,13 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost" size="sm"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-red-400" className="h-7 w-7 p-0 text-muted-foreground hover:text-red-400"
onClick={() => handleDelete(entry.filename)} onClick={() => handleDelete(entry.filename)}
disabled={isDeleting} disabled={isDeleting}
title="Delete" title="Delete"
> >
{isDeleting ? ( {isDeleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button> </Button>
</div> </div>
</div> </div>