diff --git a/AppImage/ProxMenux-1.2.2.2-beta.AppImage b/AppImage/ProxMenux-1.2.2.2-beta.AppImage index 4be06157..82c0867e 100755 Binary files a/AppImage/ProxMenux-1.2.2.2-beta.AppImage and b/AppImage/ProxMenux-1.2.2.2-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 0335f8e8..34594f58 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -79be8caa3baebc9276e409c1ef1e228e33265279df41a03eba8bcc859f67a44d ProxMenux-1.2.2.2-beta.AppImage +fd5f754a35e9657ff4868dba0d5e22c0a9538794e5c176137c33e87f9554c587 ProxMenux-1.2.2.2-beta.AppImage diff --git a/AppImage/components/disk-temperature-card.tsx b/AppImage/components/disk-temperature-card.tsx index b892f0cb..6210afee 100644 --- a/AppImage/components/disk-temperature-card.tsx +++ b/AppImage/components/disk-temperature-card.tsx @@ -64,8 +64,11 @@ export function DiskTemperatureCard({ const fetchHistory = async () => { setLoading(true) try { + // 24-h timeframe gives a more useful "is this drive trending + // up over a day" view; the 1-h window was too short to spot + // anything that mattered. const result = await fetchApi<{ data: TempPoint[] }>( - `/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=hour`, + `/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=day`, ) if (cancelled.current) return setData(result?.data || []) diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 5a8d6b9c..c363b72f 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -284,6 +284,41 @@ export function StorageOverview() { } } + // Tiny coloured dot that prefixes status / counter values. Adds + // accessibility-friendly redundancy (colour + position) to fields + // that today rely on colour alone, so an "all OK" disk reads as + // visually quiet and a degraded one as immediately noisy. + // + // Colour mapping: + // - "ok" → green (passed / 0 errors) + // - "warn" → amber (1+ errors but not critical) + // - "fail" → red (failed / many errors) + const StatusDot = ({ tone }: { tone: "ok" | "warn" | "fail" }) => { + const cls = + tone === "ok" ? "bg-green-500" : tone === "warn" ? "bg-yellow-500" : "bg-red-500" + return ( + + ) + } + // Decide the tone for a counter where 0 is healthy. The "warn" / + // "fail" cutoffs are conservative — even a single reallocated + // sector is worth amber attention, and double digits start hinting + // at progressive failure (red). + const counterTone = (n: number | null | undefined): "ok" | "warn" | "fail" => { + if (!n || n <= 0) return "ok" + if (n < 10) return "warn" + return "fail" + } + const smartStatusTone = (s: string | undefined): "ok" | "warn" | "fail" => { + const v = (s || "").toLowerCase() + if (v === "passed" || v === "ok") return "ok" + if (v === "failed") return "fail" + return "warn" + } + // Renders either the live temperature or a "Standby" badge for a // spun-down drive. Centralised here because the same pattern shows up // in 4 different disk-list views (system / data / pool / other) and we @@ -313,6 +348,237 @@ export function StorageOverview() { return null } + // ────────────────────────────────────────────────────────────────── + // Disk card layout (ghosthvj-style) + // ────────────────────────────────────────────────────────────────── + // Single card per disk, grid-arranged at the parent level. Two-line + // header (identity + status / size + temp), separator, vertical + // key→value stat list with WEAR LEVEL bar when available, and a + // footer with serial + "Ver detalles →". Replaces the previous + // duplicated mobile/desktop full-width rows. + const renderDiskCardV2 = (disk: DiskInfo) => { + const type = getDiskTypeBadge(disk.name, disk.rotation_rate) + // Pick the most relevant wear metric for the device class. + // NVMe uses `percentage_used` (0 = fresh, 100 = TBW spent). + // SSD may expose `media_wearout_indicator` (decreasing 100→0) or + // `ssd_life_left` (decreasing 100→0). Normalise both to a + // "percentage spent" so the bar always fills LEFT to RIGHT as the + // drive ages — visually consistent across vendors. + let wearPct: number | null = null + if (typeof disk.percentage_used === "number") wearPct = disk.percentage_used + else if (typeof disk.ssd_life_left === "number") wearPct = 100 - disk.ssd_life_left + else if (typeof disk.media_wearout_indicator === "number") + wearPct = 100 - disk.media_wearout_indicator + // Wear bar always uses the same blue as the modal's wear visual, + // even when the wear is high — the colour is the SECTION colour, + // not a severity signal. The percentage value itself (and the + // surrounding stats) already communicate health via the dot + // colours, so flipping the bar to amber/red here would just + // double-encode the same thing and break visual consistency + // with the detail modal. + const wearColor = wearPct === null ? "" : "bg-blue-500" + const cleanSerial = (disk.serial || "").replace(/\\x[0-9a-fA-F]{2}/g, "") + + return ( +
handleDiskClick(disk)} + > + {/* Header line 1: identity + SMART status (right). */} +
+
+

/dev/{disk.name}

+ {type.label} + {disk.is_system_disk && ( + + + System + + )} + {disk.connection_type === "usb" && ( + + + USB + + )} +
+ {disk.smart_status && disk.smart_status !== "unknown" && ( + + + {disk.smart_status} + + )} +
+ + {/* Header line 2: size + temperature/standby. */} +
+ {disk.size_formatted} + {disk.standby ? ( + + + Standby + + ) : disk.temperature > 0 ? ( + + {disk.temperature}°C + + ) : null} +
+ + {/* I/O errors banner (preserved from the previous design). */} + {disk.io_errors && disk.io_errors.count > 0 && ( +
+ + + {disk.io_errors.error_type === "filesystem" + ? "Filesystem corruption detected" + : `${disk.io_errors.count} I/O error${ + disk.io_errors.count !== 1 ? "s" : "" + } in 5 min`} + +
+ )} + + {/* Separator. */} +
+ + {/* Stats: vertical key→value list. Each row matches the + "uppercase label left · value right" pattern from ghosthvj. */} +
+ {disk.model && disk.model !== "Unknown" && ( +
+ + Model + + {disk.model} +
+ )} + {wearPct !== null && ( +
+
+ + Wear Level + + {wearPct}% +
+
+
+
+
+ )} + {disk.power_cycles !== undefined && disk.power_cycles > 0 && ( +
+ + Power Cycles + + {disk.power_cycles.toLocaleString()} +
+ )} + {disk.power_on_hours !== undefined && disk.power_on_hours > 0 && ( +
+ + Power On + + {formatHours(disk.power_on_hours)} +
+ )} + {disk.crc_errors !== undefined && ( +
+ + CRC Errors + + + + {disk.crc_errors} + +
+ )} + {/* Reallocated only meaningful on rotating disks. */} + {disk.reallocated_sectors !== undefined && (disk.rotation_rate ?? 0) > 0 && ( +
+ + Realloc. Sectors + + + + {disk.reallocated_sectors} + +
+ )} +
+ + {/* Footer: serial (left, white mono for legibility) + observations + + arrow-only CTA (language-neutral, compact). */} +
+ {cleanSerial && cleanSerial !== "Unknown" ? ( + + S/N: {cleanSerial} + + ) : ( + + )} +
+ {(disk.observations_count ?? 0) > 0 && ( + + + {disk.observations_count} + + )} + + → + +
+
+
+ ) + } + const getTempColor = (temp: number, diskName?: string, rotationRate?: number) => { if (temp === 0) return "text-gray-500" @@ -333,12 +599,24 @@ export function StorageOverview() { const formatHours = (hours: number) => { if (hours === 0) return "N/A" - const years = Math.floor(hours / 8760) - const days = Math.floor((hours % 8760) / 24) + // Render in years + months when ≥1 year (e.g. "2y 6m" instead of + // "2y 189d" — months are easier to picture than triple-digit + // residual days). Months use 30.44 d/mo average to round cleanly. + // <30 days: keep days. 30 d–1 yr: months + residual days when both + // values are meaningful. + const totalDays = Math.floor(hours / 24) + if (totalDays < 30) return `${totalDays}d` + const years = Math.floor(totalDays / 365) + const remainingAfterYears = totalDays - years * 365 + const months = Math.floor(remainingAfterYears / 30) if (years > 0) { - return `${years}y ${days}d` + return months > 0 ? `${years}y ${months}m` : `${years}y` } - return `${days}d` + // Sub-year: show months + residual days if both are non-trivial. + const residualDays = remainingAfterYears - months * 30 + if (months > 0 && residualDays > 0) return `${months}m ${residualDays}d` + if (months > 0) return `${months}m` + return `${totalDays}d` } const formatRotationRate = (rpm: number | undefined) => { @@ -1379,184 +1657,10 @@ export function StorageOverview() { -
- {storageData.disks.filter(d => d.connection_type !== 'usb').map((disk) => ( -
-
handleDiskClick(disk)} - > -
- {/* Row 1: Device name and type badge */} -
- -

/dev/{disk.name}

- - {getDiskTypeBadge(disk.name, disk.rotation_rate).label} - - {disk.is_system_disk && ( - - - System - - )} -
- - {/* Row 2: Model, temperature, and health status */} -
- {disk.model && disk.model !== "Unknown" && ( -

{disk.model}

- )} -
- {renderDiskTempOrStandby(disk)} - {(disk.observations_count ?? 0) > 0 && ( - - - {disk.observations_count} obs. - - )} - {getHealthBadge(disk.health)} -
-
-
- - {disk.io_errors && disk.io_errors.count > 0 && ( -
- - - {disk.io_errors.error_type === 'filesystem' - ? `Filesystem corruption detected` - : `${disk.io_errors.count} I/O error${disk.io_errors.count !== 1 ? 's' : ''} in 5 min`} - -
- )} - -
- {disk.size_formatted && ( -
-

Size

-

{disk.size_formatted}

-
- )} - {disk.smart_status && disk.smart_status !== "unknown" && ( -
-

SMART Status

-

{disk.smart_status}

-
- )} - {disk.power_on_hours !== undefined && disk.power_on_hours > 0 && ( -
-

Power On Time

-

{formatHours(disk.power_on_hours)}

-
- )} - {disk.serial && disk.serial !== "Unknown" && ( -
-

Serial

-

{disk.serial}

-
- )} -
-
- -
handleDiskClick(disk)} - > -
- {/* Row 1: Device name and type badge */} -
- -

/dev/{disk.name}

- - {getDiskTypeBadge(disk.name, disk.rotation_rate).label} - - {disk.is_system_disk && ( - - - System - - )} -
- - {/* Row 2: Model, temperature, and health status */} -
- {disk.model && disk.model !== "Unknown" && ( -

{disk.model}

- )} -
- {renderDiskTempOrStandby(disk)} - {(disk.observations_count ?? 0) > 0 && ( - - - {disk.observations_count} obs. - - )} - {getHealthBadge(disk.health)} -
-
-
- - {disk.io_errors && disk.io_errors.count > 0 && ( -
- -
- {disk.io_errors.error_type === 'filesystem' ? ( - <> - Filesystem corruption detected - {disk.io_errors.reason && ( -

{disk.io_errors.reason}

- )} - - ) : ( - <> - {disk.io_errors.count} I/O error{disk.io_errors.count !== 1 ? 's' : ''} in 5 min - {disk.io_errors.sample && ( -

{disk.io_errors.sample}

- )} - - )} -
-
- )} - -
- {disk.size_formatted && ( -
-

Size

-

{disk.size_formatted}

-
- )} - {disk.smart_status && disk.smart_status !== "unknown" && ( -
-

SMART Status

-

{disk.smart_status}

-
- )} - {disk.power_on_hours !== undefined && disk.power_on_hours > 0 && ( -
-

Power On Time

-

{formatHours(disk.power_on_hours)}

-
- )} - {disk.serial && disk.serial !== "Unknown" && ( -
-

Serial

-

{disk.serial.replace(/\\x[0-9a-fA-F]{2}/g, '')}

-
- )} -
-
-
- ))} +
+ {storageData.disks + .filter((d) => d.connection_type !== 'usb') + .map((disk) => renderDiskCardV2(disk))}
@@ -1571,148 +1675,17 @@ export function StorageOverview() { -
- {storageData.disks.filter(d => d.connection_type === 'usb').map((disk) => ( -
- {/* Mobile card */} -
handleDiskClick(disk)} - > -
-
- -

/dev/{disk.name}

- USB -
-
- {disk.model && disk.model !== "Unknown" && ( -

{disk.model}

- )} -
- {renderDiskTempOrStandby(disk)} - {(disk.observations_count ?? 0) > 0 && ( - - - {disk.observations_count} - - )} - {getHealthBadge(disk.health)} -
-
-
- - {/* USB Mobile: Size, SMART, Serial grid */} -
- {disk.size_formatted && ( -
-

Size

-

{disk.size_formatted}

-
- )} - {disk.smart_status && disk.smart_status !== "unknown" && ( -
-

SMART Status

-

{disk.smart_status}

-
- )} -{disk.serial && disk.serial !== "Unknown" && ( -
-

Serial

-

{disk.serial.replace(/\\x[0-9a-fA-F]{2}/g, '')}

-
- )} -
-
- - {/* Desktop */} -
handleDiskClick(disk)} - > -
-
- -

/dev/{disk.name}

- USB -
-
- {renderDiskTempOrStandby(disk)} - {getHealthBadge(disk.health)} - {(disk.observations_count ?? 0) > 0 && ( - - - {disk.observations_count} obs. - - )} -
-
- {disk.model && disk.model !== "Unknown" && ( -

{disk.model}

- )} - - {disk.io_errors && disk.io_errors.count > 0 && ( -
- -
- {disk.io_errors.error_type === 'filesystem' ? ( - <> - Filesystem corruption detected - {disk.io_errors.reason && ( -

{disk.io_errors.reason}

- )} - - ) : ( - <> - {disk.io_errors.count} I/O error{disk.io_errors.count !== 1 ? 's' : ''} in 5 min - {disk.io_errors.sample && ( -

{disk.io_errors.sample}

- )} - - )} -
-
- )} - -
- {disk.size_formatted && ( -
-

Size

-

{disk.size_formatted}

-
- )} - {disk.smart_status && disk.smart_status !== "unknown" && ( -
-

SMART Status

-

{disk.smart_status}

-
- )} - {disk.power_on_hours !== undefined && disk.power_on_hours > 0 && ( -
-

Power On Time

-

{formatHours(disk.power_on_hours)}

-
- )} - {disk.serial && disk.serial !== "Unknown" && ( -
-

Serial

-

{disk.serial.replace(/\\x[0-9a-fA-F]{2}/g, '')}

-
- )} -
-
-
- ))} +
+ {storageData.disks + .filter((d) => d.connection_type === 'usb') + .map((disk) => renderDiskCardV2(disk))}
)} - {/* Disk Details Dialog */} + {/* Disk Details Dialog — wider on desktop so the SMART chart + + tabs have room without the right column hugging the edge. */} { setDetailsOpen(open) if (!open) { @@ -1720,7 +1693,7 @@ export function StorageOverview() { setSmartJsonData(null) } }}> - + {selectedDisk?.connection_type === 'usb' ? ( @@ -2045,29 +2018,53 @@ export function StorageOverview() {

SMART Status

-

{selectedDisk.smart_status}

+

+ + {selectedDisk.smart_status} +

Reallocated Sectors

-

0 ? "text-yellow-500" : ""}`} - > +

+ {selectedDisk.reallocated_sectors ?? 0}

Pending Sectors

-

0 ? "text-yellow-500" : ""}`} - > +

+ {selectedDisk.pending_sectors ?? 0}

CRC Errors

-

0 ? "text-yellow-500" : ""}`} - > +

+ {selectedDisk.crc_errors ?? 0}