mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-02 13:56:23 +00:00
update 1.2.2.2 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
79be8caa3baebc9276e409c1ef1e228e33265279df41a03eba8bcc859f67a44d ProxMenux-1.2.2.2-beta.AppImage
|
fd5f754a35e9657ff4868dba0d5e22c0a9538794e5c176137c33e87f9554c587 ProxMenux-1.2.2.2-beta.AppImage
|
||||||
|
|||||||
@@ -64,8 +64,11 @@ export function DiskTemperatureCard({
|
|||||||
const fetchHistory = async () => {
|
const fetchHistory = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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[] }>(
|
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
|
if (cancelled.current) return
|
||||||
setData(result?.data || [])
|
setData(result?.data || [])
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full shrink-0 ${cls}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 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
|
// Renders either the live temperature or a "Standby" badge for a
|
||||||
// spun-down drive. Centralised here because the same pattern shows up
|
// spun-down drive. Centralised here because the same pattern shows up
|
||||||
// in 4 different disk-list views (system / data / pool / other) and we
|
// in 4 different disk-list views (system / data / pool / other) and we
|
||||||
@@ -313,6 +348,237 @@ export function StorageOverview() {
|
|||||||
return null
|
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 (
|
||||||
|
<div
|
||||||
|
key={disk.name}
|
||||||
|
className="border border-white/10 rounded-lg p-5 cursor-pointer bg-card hover:bg-white/5 transition-colors flex flex-col"
|
||||||
|
onClick={() => handleDiskClick(disk)}
|
||||||
|
>
|
||||||
|
{/* Header line 1: identity + SMART status (right). */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
|
<h3 className="font-mono font-bold text-base break-all">/dev/{disk.name}</h3>
|
||||||
|
<Badge className={type.className}>{type.label}</Badge>
|
||||||
|
{disk.is_system_disk && (
|
||||||
|
<Badge className="bg-orange-500/10 text-orange-500 border-orange-500/20 gap-1">
|
||||||
|
<Server className="h-3 w-3" />
|
||||||
|
System
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{disk.connection_type === "usb" && (
|
||||||
|
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 gap-1">
|
||||||
|
<Usb className="h-3 w-3" />
|
||||||
|
USB
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{disk.smart_status && disk.smart_status !== "unknown" && (
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wide shrink-0 ${
|
||||||
|
smartStatusTone(disk.smart_status) === "ok"
|
||||||
|
? "text-green-500"
|
||||||
|
: smartStatusTone(disk.smart_status) === "fail"
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<StatusDot tone={smartStatusTone(disk.smart_status)} />
|
||||||
|
{disk.smart_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header line 2: size + temperature/standby. */}
|
||||||
|
<div className="flex items-center justify-between gap-3 mt-1">
|
||||||
|
<span className="text-sm text-muted-foreground">{disk.size_formatted}</span>
|
||||||
|
{disk.standby ? (
|
||||||
|
<Badge
|
||||||
|
className="bg-blue-500/10 text-blue-300 border-blue-500/30 gap-1"
|
||||||
|
title="Drive is in standby — smartctl skipped to keep it spun down"
|
||||||
|
>
|
||||||
|
<Power className="h-3 w-3" />
|
||||||
|
Standby
|
||||||
|
</Badge>
|
||||||
|
) : disk.temperature > 0 ? (
|
||||||
|
<span
|
||||||
|
className={`text-base font-semibold ${getTempColor(
|
||||||
|
disk.temperature,
|
||||||
|
disk.name,
|
||||||
|
disk.rotation_rate,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{disk.temperature}°C
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* I/O errors banner (preserved from the previous design). */}
|
||||||
|
{disk.io_errors && disk.io_errors.count > 0 && (
|
||||||
|
<div
|
||||||
|
className={`mt-3 flex items-start gap-2 p-2 rounded text-xs ${
|
||||||
|
disk.io_errors.severity === "CRITICAL"
|
||||||
|
? "bg-red-500/10 text-red-400 border border-red-500/20"
|
||||||
|
: "bg-yellow-500/10 text-yellow-400 border border-yellow-500/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
{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`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Separator. */}
|
||||||
|
<div className="border-t border-border/60 my-3" />
|
||||||
|
|
||||||
|
{/* Stats: vertical key→value list. Each row matches the
|
||||||
|
"uppercase label left · value right" pattern from ghosthvj. */}
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{disk.model && disk.model !== "Unknown" && (
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
|
Model
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-right truncate font-mono text-xs">{disk.model}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{wearPct !== null && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between gap-3 mb-1">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
Wear Level
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{wearPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-muted/40 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${wearColor}`}
|
||||||
|
style={{ width: `${Math.min(100, Math.max(0, wearPct))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{disk.power_cycles !== undefined && disk.power_cycles > 0 && (
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
Power Cycles
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{disk.power_cycles.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
Power On
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{formatHours(disk.power_on_hours)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{disk.crc_errors !== undefined && (
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
CRC Errors
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium flex items-center gap-1.5 ${
|
||||||
|
counterTone(disk.crc_errors) === "ok"
|
||||||
|
? "text-green-500"
|
||||||
|
: counterTone(disk.crc_errors) === "warn"
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-red-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<StatusDot tone={counterTone(disk.crc_errors)} />
|
||||||
|
{disk.crc_errors}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Reallocated only meaningful on rotating disks. */}
|
||||||
|
{disk.reallocated_sectors !== undefined && (disk.rotation_rate ?? 0) > 0 && (
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
Realloc. Sectors
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium flex items-center gap-1.5 ${
|
||||||
|
counterTone(disk.reallocated_sectors) === "ok"
|
||||||
|
? "text-green-500"
|
||||||
|
: counterTone(disk.reallocated_sectors) === "warn"
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-red-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<StatusDot tone={counterTone(disk.reallocated_sectors)} />
|
||||||
|
{disk.reallocated_sectors}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: serial (left, white mono for legibility) + observations
|
||||||
|
+ arrow-only CTA (language-neutral, compact). */}
|
||||||
|
<div className="border-t border-border/60 mt-auto pt-3 flex items-center justify-between gap-3">
|
||||||
|
{cleanSerial && cleanSerial !== "Unknown" ? (
|
||||||
|
<span className="text-[11px] text-foreground font-mono truncate min-w-0">
|
||||||
|
<span className="text-muted-foreground">S/N:</span> {cleanSerial}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{(disk.observations_count ?? 0) > 0 && (
|
||||||
|
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1 text-[10px]">
|
||||||
|
<Info className="h-3 w-3" />
|
||||||
|
{disk.observations_count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-blue-400 hover:text-blue-300 transition-colors text-base leading-none"
|
||||||
|
aria-label="View details"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const getTempColor = (temp: number, diskName?: string, rotationRate?: number) => {
|
const getTempColor = (temp: number, diskName?: string, rotationRate?: number) => {
|
||||||
if (temp === 0) return "text-gray-500"
|
if (temp === 0) return "text-gray-500"
|
||||||
|
|
||||||
@@ -333,12 +599,24 @@ export function StorageOverview() {
|
|||||||
|
|
||||||
const formatHours = (hours: number) => {
|
const formatHours = (hours: number) => {
|
||||||
if (hours === 0) return "N/A"
|
if (hours === 0) return "N/A"
|
||||||
const years = Math.floor(hours / 8760)
|
// Render in years + months when ≥1 year (e.g. "2y 6m" instead of
|
||||||
const days = Math.floor((hours % 8760) / 24)
|
// "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) {
|
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) => {
|
const formatRotationRate = (rpm: number | undefined) => {
|
||||||
@@ -1379,184 +1657,10 @@ export function StorageOverview() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{storageData.disks.filter(d => d.connection_type !== 'usb').map((disk) => (
|
{storageData.disks
|
||||||
<div key={disk.name}>
|
.filter((d) => d.connection_type !== 'usb')
|
||||||
<div
|
.map((disk) => renderDiskCardV2(disk))}
|
||||||
className="sm:hidden border border-white/10 rounded-lg p-4 cursor-pointer bg-white/5 transition-colors"
|
|
||||||
onClick={() => handleDiskClick(disk)}
|
|
||||||
>
|
|
||||||
<div className="space-y-2 mb-3">
|
|
||||||
{/* Row 1: Device name and type badge */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
|
||||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
|
||||||
<Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}>
|
|
||||||
{getDiskTypeBadge(disk.name, disk.rotation_rate).label}
|
|
||||||
</Badge>
|
|
||||||
{disk.is_system_disk && (
|
|
||||||
<Badge className="bg-orange-500/10 text-orange-500 border-orange-500/20 gap-1">
|
|
||||||
<Server className="h-3 w-3" />
|
|
||||||
System
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Model, temperature, and health status */}
|
|
||||||
<div className="flex items-center justify-between gap-3 pl-7">
|
|
||||||
{disk.model && disk.model !== "Unknown" && (
|
|
||||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
|
||||||
{renderDiskTempOrStandby(disk)}
|
|
||||||
{(disk.observations_count ?? 0) > 0 && (
|
|
||||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
|
||||||
<Info className="h-3 w-3" />
|
|
||||||
{disk.observations_count} obs.
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{getHealthBadge(disk.health)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{disk.io_errors && disk.io_errors.count > 0 && (
|
|
||||||
<div className={`flex items-start gap-2 p-2 rounded text-xs ${
|
|
||||||
disk.io_errors.severity === 'CRITICAL'
|
|
||||||
? 'bg-red-500/10 text-red-400 border border-red-500/20'
|
|
||||||
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
|
|
||||||
}`}>
|
|
||||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
|
||||||
<span>
|
|
||||||
{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`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
{disk.size_formatted && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Size</p>
|
|
||||||
<p className="font-medium">{disk.size_formatted}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.smart_status && disk.smart_status !== "unknown" && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
|
||||||
<p className="font-medium capitalize">{disk.smart_status}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Power On Time</p>
|
|
||||||
<p className="font-medium">{formatHours(disk.power_on_hours)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.serial && disk.serial !== "Unknown" && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Serial</p>
|
|
||||||
<p className="font-medium text-xs">{disk.serial}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="hidden sm:block border border-white/10 rounded-lg p-4 cursor-pointer bg-card hover:bg-white/5 transition-colors"
|
|
||||||
onClick={() => handleDiskClick(disk)}
|
|
||||||
>
|
|
||||||
<div className="space-y-2 mb-3">
|
|
||||||
{/* Row 1: Device name and type badge */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
|
||||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
|
||||||
<Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}>
|
|
||||||
{getDiskTypeBadge(disk.name, disk.rotation_rate).label}
|
|
||||||
</Badge>
|
|
||||||
{disk.is_system_disk && (
|
|
||||||
<Badge className="bg-orange-500/10 text-orange-500 border-orange-500/20 gap-1">
|
|
||||||
<Server className="h-3 w-3" />
|
|
||||||
System
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Model, temperature, and health status */}
|
|
||||||
<div className="flex items-center justify-between gap-3 pl-7">
|
|
||||||
{disk.model && disk.model !== "Unknown" && (
|
|
||||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
|
||||||
{renderDiskTempOrStandby(disk)}
|
|
||||||
{(disk.observations_count ?? 0) > 0 && (
|
|
||||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
|
||||||
<Info className="h-3 w-3" />
|
|
||||||
{disk.observations_count} obs.
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{getHealthBadge(disk.health)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{disk.io_errors && disk.io_errors.count > 0 && (
|
|
||||||
<div className={`flex items-start gap-2 p-2 rounded text-xs ${
|
|
||||||
disk.io_errors.severity === 'CRITICAL'
|
|
||||||
? 'bg-red-500/10 text-red-400 border border-red-500/20'
|
|
||||||
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
|
|
||||||
}`}>
|
|
||||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
{disk.io_errors.error_type === 'filesystem' ? (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">Filesystem corruption detected</span>
|
|
||||||
{disk.io_errors.reason && (
|
|
||||||
<p className="mt-0.5 opacity-90 whitespace-pre-line">{disk.io_errors.reason}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">{disk.io_errors.count} I/O error{disk.io_errors.count !== 1 ? 's' : ''} in 5 min</span>
|
|
||||||
{disk.io_errors.sample && (
|
|
||||||
<p className="mt-0.5 opacity-80 font-mono truncate max-w-md">{disk.io_errors.sample}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
||||||
{disk.size_formatted && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Size</p>
|
|
||||||
<p className="font-medium">{disk.size_formatted}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.smart_status && disk.smart_status !== "unknown" && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
|
||||||
<p className="font-medium capitalize">{disk.smart_status}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Power On Time</p>
|
|
||||||
<p className="font-medium">{formatHours(disk.power_on_hours)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.serial && disk.serial !== "Unknown" && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Serial</p>
|
|
||||||
<p className="font-medium text-xs">{disk.serial.replace(/\\x[0-9a-fA-F]{2}/g, '')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1571,148 +1675,17 @@ export function StorageOverview() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{storageData.disks.filter(d => d.connection_type === 'usb').map((disk) => (
|
{storageData.disks
|
||||||
<div key={disk.name}>
|
.filter((d) => d.connection_type === 'usb')
|
||||||
{/* Mobile card */}
|
.map((disk) => renderDiskCardV2(disk))}
|
||||||
<div
|
|
||||||
className="sm:hidden border border-white/10 rounded-lg p-4 cursor-pointer bg-white/5 transition-colors"
|
|
||||||
onClick={() => handleDiskClick(disk)}
|
|
||||||
>
|
|
||||||
<div className="space-y-2 mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Usb className="h-5 w-5 text-orange-400 flex-shrink-0" />
|
|
||||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
|
||||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3 pl-7">
|
|
||||||
{disk.model && disk.model !== "Unknown" && (
|
|
||||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
|
||||||
{renderDiskTempOrStandby(disk)}
|
|
||||||
{(disk.observations_count ?? 0) > 0 && (
|
|
||||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
|
||||||
<Info className="h-3 w-3" />
|
|
||||||
{disk.observations_count}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{getHealthBadge(disk.health)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* USB Mobile: Size, SMART, Serial grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
{disk.size_formatted && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Size</p>
|
|
||||||
<p className="font-medium">{disk.size_formatted}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.smart_status && disk.smart_status !== "unknown" && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
|
||||||
<p className="font-medium capitalize">{disk.smart_status}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.serial && disk.serial !== "Unknown" && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Serial</p>
|
|
||||||
<p className="font-medium text-xs">{disk.serial.replace(/\\x[0-9a-fA-F]{2}/g, '')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop */}
|
|
||||||
<div
|
|
||||||
className="hidden sm:block border border-white/10 rounded-lg p-4 cursor-pointer hover:bg-white/5 transition-colors"
|
|
||||||
onClick={() => handleDiskClick(disk)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Usb className="h-5 w-5 text-orange-400" />
|
|
||||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
|
||||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{renderDiskTempOrStandby(disk)}
|
|
||||||
{getHealthBadge(disk.health)}
|
|
||||||
{(disk.observations_count ?? 0) > 0 && (
|
|
||||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
|
||||||
<Info className="h-3 w-3" />
|
|
||||||
{disk.observations_count} obs.
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{disk.model && disk.model !== "Unknown" && (
|
|
||||||
<p className="text-sm text-muted-foreground mb-3 ml-7">{disk.model}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{disk.io_errors && disk.io_errors.count > 0 && (
|
|
||||||
<div className={`flex items-start gap-2 p-2 rounded text-xs mb-3 ${
|
|
||||||
disk.io_errors.severity === 'CRITICAL'
|
|
||||||
? 'bg-red-500/10 text-red-400 border border-red-500/20'
|
|
||||||
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
|
|
||||||
}`}>
|
|
||||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
{disk.io_errors.error_type === 'filesystem' ? (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">Filesystem corruption detected</span>
|
|
||||||
{disk.io_errors.reason && (
|
|
||||||
<p className="mt-0.5 opacity-90 whitespace-pre-line">{disk.io_errors.reason}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">{disk.io_errors.count} I/O error{disk.io_errors.count !== 1 ? 's' : ''} in 5 min</span>
|
|
||||||
{disk.io_errors.sample && (
|
|
||||||
<p className="mt-0.5 opacity-80 font-mono truncate max-w-md">{disk.io_errors.sample}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
||||||
{disk.size_formatted && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Size</p>
|
|
||||||
<p className="font-medium">{disk.size_formatted}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.smart_status && disk.smart_status !== "unknown" && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
|
||||||
<p className="font-medium capitalize">{disk.smart_status}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Power On Time</p>
|
|
||||||
<p className="font-medium">{formatHours(disk.power_on_hours)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{disk.serial && disk.serial !== "Unknown" && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Serial</p>
|
|
||||||
<p className="font-medium text-xs">{disk.serial.replace(/\\x[0-9a-fA-F]{2}/g, '')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Disk Details Dialog */}
|
{/* Disk Details Dialog — wider on desktop so the SMART chart +
|
||||||
|
tabs have room without the right column hugging the edge. */}
|
||||||
<Dialog open={detailsOpen} onOpenChange={(open) => {
|
<Dialog open={detailsOpen} onOpenChange={(open) => {
|
||||||
setDetailsOpen(open)
|
setDetailsOpen(open)
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -1720,7 +1693,7 @@ export function StorageOverview() {
|
|||||||
setSmartJsonData(null)
|
setSmartJsonData(null)
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
|
<DialogContent className="max-w-4xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
|
||||||
<DialogHeader className="px-6 pt-6 pb-0">
|
<DialogHeader className="px-6 pt-6 pb-0">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{selectedDisk?.connection_type === 'usb' ? (
|
{selectedDisk?.connection_type === 'usb' ? (
|
||||||
@@ -2045,29 +2018,53 @@ export function StorageOverview() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
<p className="text-sm text-muted-foreground">SMART Status</p>
|
||||||
<p className="font-medium capitalize">{selectedDisk.smart_status}</p>
|
<p className={`font-medium capitalize flex items-center gap-1.5 ${
|
||||||
|
smartStatusTone(selectedDisk.smart_status) === "ok"
|
||||||
|
? "text-green-500"
|
||||||
|
: smartStatusTone(selectedDisk.smart_status) === "fail"
|
||||||
|
? "text-red-500"
|
||||||
|
: ""
|
||||||
|
}`}>
|
||||||
|
<StatusDot tone={smartStatusTone(selectedDisk.smart_status)} />
|
||||||
|
{selectedDisk.smart_status}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Reallocated Sectors</p>
|
<p className="text-sm text-muted-foreground">Reallocated Sectors</p>
|
||||||
<p
|
<p className={`font-medium flex items-center gap-1.5 ${
|
||||||
className={`font-medium ${selectedDisk.reallocated_sectors && selectedDisk.reallocated_sectors > 0 ? "text-yellow-500" : ""}`}
|
counterTone(selectedDisk.reallocated_sectors) === "ok"
|
||||||
>
|
? "text-green-500"
|
||||||
|
: counterTone(selectedDisk.reallocated_sectors) === "warn"
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-red-500"
|
||||||
|
}`}>
|
||||||
|
<StatusDot tone={counterTone(selectedDisk.reallocated_sectors)} />
|
||||||
{selectedDisk.reallocated_sectors ?? 0}
|
{selectedDisk.reallocated_sectors ?? 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Pending Sectors</p>
|
<p className="text-sm text-muted-foreground">Pending Sectors</p>
|
||||||
<p
|
<p className={`font-medium flex items-center gap-1.5 ${
|
||||||
className={`font-medium ${selectedDisk.pending_sectors && selectedDisk.pending_sectors > 0 ? "text-yellow-500" : ""}`}
|
counterTone(selectedDisk.pending_sectors) === "ok"
|
||||||
>
|
? "text-green-500"
|
||||||
|
: counterTone(selectedDisk.pending_sectors) === "warn"
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-red-500"
|
||||||
|
}`}>
|
||||||
|
<StatusDot tone={counterTone(selectedDisk.pending_sectors)} />
|
||||||
{selectedDisk.pending_sectors ?? 0}
|
{selectedDisk.pending_sectors ?? 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">CRC Errors</p>
|
<p className="text-sm text-muted-foreground">CRC Errors</p>
|
||||||
<p
|
<p className={`font-medium flex items-center gap-1.5 ${
|
||||||
className={`font-medium ${selectedDisk.crc_errors && selectedDisk.crc_errors > 0 ? "text-yellow-500" : ""}`}
|
counterTone(selectedDisk.crc_errors) === "ok"
|
||||||
>
|
? "text-green-500"
|
||||||
|
: counterTone(selectedDisk.crc_errors) === "warn"
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-red-500"
|
||||||
|
}`}>
|
||||||
|
<StatusDot tone={counterTone(selectedDisk.crc_errors)} />
|
||||||
{selectedDisk.crc_errors ?? 0}
|
{selectedDisk.crc_errors ?? 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user