mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-29 19:06:37 +00:00
Update storage-overview.tsx
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user