Files
ProxMenux/AppImage/components/storage-overview.tsx
2026-06-24 23:40:22 +02:00

4748 lines
230 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { fetchApi } from "../lib/api-config"
import { formatStorage as sharedFormatStorage } from "../lib/utils"
import { DiskTemperatureDetailModal } from "./disk-temperature-detail-modal"
import { DiskTemperatureCard } from "./disk-temperature-card"
import { getDiskType as resolveDiskType } from "../lib/disk-type"
import {
useDiskTempThresholds,
loadDiskTempThresholds,
getDiskTempThresholdsSync,
type DiskTempMap,
} from "../lib/health-thresholds"
interface DiskInfo {
name: string
size?: number // Changed from string to number (KB) for formatMemory()
size_formatted?: string // Added formatted size string for display
temperature: number
// True when the temperature poller's last smartctl exited with
// "device is in standby". The UI uses this to render a Standby
// badge AND to suppress the (stale) temperature value, so the
// operator understands the graph is frozen on purpose — issue #232.
standby?: boolean
health: string
power_on_hours?: number
smart_status?: string
model?: string
serial?: string
mountpoint?: string
fstype?: string
total?: number
used?: number
available?: number
usage_percent?: number
reallocated_sectors?: number
pending_sectors?: number
crc_errors?: number
rotation_rate?: number
power_cycles?: number
percentage_used?: number // NVMe: Percentage Used (0-100)
media_wearout_indicator?: number // SSD: Media Wearout Indicator
wear_leveling_count?: number // SSD: Wear Leveling Count
total_lbas_written?: number // SSD/NVMe: Total LBAs Written (GB)
ssd_life_left?: number // SSD: SSD Life Left percentage
io_errors?: {
count: number
severity: string
sample: string
reason: string
error_type?: string // 'io' | 'filesystem'
}
observations_count?: number
connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
removable?: boolean
is_system_disk?: boolean
system_usage?: string[]
}
interface DiskObservation {
id: number
error_type: string
error_signature: string
first_occurrence: string
last_occurrence: string
occurrence_count: number
raw_message: string
severity: string
dismissed: boolean
device_name: string
serial: string
model: string
}
interface ZFSPool {
name: string
size: string
allocated: string
free: string
health: string
}
interface StorageData {
total: number
used: number
available: number
disks: DiskInfo[]
zfs_pools: ZFSPool[]
disk_count: number
healthy_disks: number
warning_disks: number
critical_disks: number
error?: string
}
interface ProxmoxStorage {
name: string
type: string
status: string
total: number
used: number
available: number
percent: number
node: string // Added node property for detailed debug logging
}
interface ProxmoxStorageData {
storage: ProxmoxStorage[]
error?: string
}
// Sprint 13: shape returned by /api/mounts. Lists every NFS/CIFS/SMB
// mount on the host with a per-mount health status — complements the
// PVE-storage list above with arbitrary mounts done outside PVE
// (fstab, manual `mount` commands).
interface RemoteMount {
source: string
target: string
fstype: string
options: string
readonly: boolean
reachable: boolean
error?: string | null
status: "ok" | "stale" | "readonly"
// Sprint 13.16: extra fields the modal renders. Backend fills them
// when the mount is reachable; nullable when df couldn't run (stale).
proxmox_managed?: boolean
total_bytes?: number | null
used_bytes?: number | null
available_bytes?: number | null
// Sprint 13.24: present only on LXC-internal mounts.
lxc_id?: string
lxc_name?: string
lxc_pid?: string
}
interface RemoteMountsData {
mounts: RemoteMount[]
lxc_mounts?: RemoteMount[]
available: boolean
error?: string
}
// Re-exported under the local name so the rest of this large file
// stays untouched. Single source of truth lives in lib/utils.ts.
const formatStorage = sharedFormatStorage
// Translate the short ATA/SCSI error codes that appear inside `{ ... }`
// in a raw kernel observation (e.g. `error: { IDNF }`) into a one-line
// human description. Mirrors `_translate_ata_error` in
// notification_events.py — kept here so both the dialog and the printable
// SMART report can render a friendlier line under the raw message
// without round-tripping to the backend. Returns null when no recognised
// code is present, so the caller hides the extra line for non-ATA rows.
function translateAtaError(raw: string): string | null {
if (!raw) return null
const ATA_CODES: Record<string, string> = {
IDNF: 'Sector address not found — possible bad sector or cable issue',
UNC: 'Uncorrectable read error — bad sector',
ABRT: 'Command aborted by drive',
AMNF: 'Address mark not found — surface damage',
TK0NF: 'Track 0 not found — drive hardware failure',
BBK: 'Bad block detected',
ICRC: 'Interface CRC error — cable or connector issue',
MC: 'Media changed',
MCR: 'Media change requested',
WP: 'Write protected',
}
const m = raw.match(/\{\s*([A-Z0-9 ]+)\s*\}/)
if (!m) return null
const codes = m[1].split(/\s+/).filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const c of codes) {
const desc = ATA_CODES[c]
if (desc && !seen.has(c)) {
seen.add(c)
out.push(desc)
}
}
return out.length ? out.join('; ') : null
}
export function StorageOverview() {
// User-configurable disk temperature thresholds (Settings → Health
// Monitor Thresholds). Until the API responds the hook returns
// sensible defaults from `lib/health-thresholds`, so first paint
// never blocks on the network.
const dtThresholds = useDiskTempThresholds()
const [storageData, setStorageData] = useState<StorageData | null>(null)
const [proxmoxStorage, setProxmoxStorage] = useState<ProxmoxStorageData | null>(null)
const [remoteMounts, setRemoteMounts] = useState<RemoteMount[]>([])
// Sprint 13.19: detail modal for a single remote mount. Tracks the
// mount object itself rather than just an id so a stale data fetch
// can't leave the modal showing nothing.
const [mountDetail, setMountDetail] = useState<RemoteMount | null>(null)
const [loading, setLoading] = useState(true)
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
const [loadingObservations, setLoadingObservations] = useState(false)
const [activeModalTab, setActiveModalTab] = useState<"overview" | "smart" | "history" | "schedule">("overview")
const [smartJsonData, setSmartJsonData] = useState<{
has_data: boolean
data?: Record<string, unknown>
timestamp?: string
test_type?: string
history?: Array<{ filename: string; timestamp: string; test_type: string; date_readable: string }>
} | null>(null)
const [loadingSmartJson, setLoadingSmartJson] = useState(false)
const [tempHistoryDisk, setTempHistoryDisk] = useState<DiskInfo | null>(null)
const fetchStorageData = async () => {
try {
const [data, proxmoxData, mountsData] = await Promise.all([
fetchApi<StorageData>("/api/storage"),
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
// Sprint 13 — host-level NFS/CIFS/SMB mounts. Wrapped in catch
// so a failure here doesn't blank the whole storage tab.
fetchApi<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)),
])
setStorageData(data)
setProxmoxStorage(proxmoxData)
setRemoteMounts(mountsData?.mounts || [])
} catch (error) {
console.error("Error fetching storage data:", error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchStorageData()
const interval = setInterval(fetchStorageData, 30000)
return () => clearInterval(interval)
}, [])
const getHealthIcon = (health: string) => {
switch (health.toLowerCase()) {
case "healthy":
case "passed":
case "online":
return <CheckCircle2 className="h-5 w-5 text-green-500" />
case "warning":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />
case "critical":
case "failed":
case "degraded":
return <XCircle className="h-5 w-5 text-red-500" />
default:
return <AlertTriangle className="h-5 w-5 text-gray-500" />
}
}
const getHealthBadge = (health: string) => {
switch (health.toLowerCase()) {
case "healthy":
case "passed":
case "online":
return <Badge className="bg-green-500/10 text-green-500 border-green-500/20">Healthy</Badge>
case "warning":
return <Badge className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">Warning</Badge>
case "critical":
case "failed":
case "degraded":
return <Badge className="bg-red-500/10 text-red-500 border-red-500/20">Critical</Badge>
default:
return <Badge className="bg-gray-500/10 text-gray-500 border-gray-500/20">Unknown</Badge>
}
}
// 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
// spun-down drive. Centralised here because the same pattern shows up
// in 4 different disk-list views (system / data / pool / other) and we
// want them all to behave identically — issue #232 fix.
const renderDiskTempOrStandby = (disk: DiskInfo) => {
if (disk.standby) {
return (
<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>
)
}
if (disk.temperature > 0) {
return (
<div className="flex items-center gap-1">
<Thermometer className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`} />
<span className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}>
{disk.temperature}°C
</span>
</div>
)
}
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) => {
if (temp === 0) return "text-gray-500"
// Resolve disk class → threshold pair from the user-configurable
// backend (single source of truth). The semantics: temp BELOW warn
// is green, between warn and hot is amber, hot or above is red.
let cls: keyof DiskTempMap = "HDD"
if (diskName?.startsWith("nvme")) {
cls = "NVMe"
} else if (!rotationRate || rotationRate === 0) {
cls = "SSD"
}
const t = dtThresholds[cls]
if (temp >= t.hot) return "text-red-500"
if (temp >= t.warn) return "text-yellow-500"
return "text-green-500"
}
const formatHours = (hours: number) => {
if (hours === 0) return "N/A"
// 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 d1 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 months > 0 ? `${years}y ${months}m` : `${years}y`
}
// 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) => {
if (!rpm || rpm === 0) return "SSD"
return `${rpm.toLocaleString()} RPM`
}
// Thin wrapper over the shared classifier so the rest of the file
// doesn't need to be touched. The actual rules live in
// lib/disk-type.ts (single source of truth across Storage page,
// Hardware page, and any future consumer).
const getDiskType = (diskName: string, rotationRate: number | undefined): string =>
resolveDiskType(diskName, rotationRate)
const getDiskTypeBadge = (diskName: string, rotationRate: number | undefined) => {
const diskType = getDiskType(diskName, rotationRate)
const badgeStyles: Record<string, { className: string; label: string }> = {
NVMe: {
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
label: "NVMe",
},
SSD: {
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
label: "SSD",
},
HDD: {
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
label: "HDD",
},
}
return badgeStyles[diskType]
}
const handleDiskClick = async (disk: DiskInfo) => {
setSelectedDisk(disk)
setDetailsOpen(true)
setDiskObservations([])
setSmartJsonData(null)
// Fetch observations and SMART JSON data in parallel
setLoadingObservations(true)
setLoadingSmartJson(true)
// Fetch observations
const fetchObservations = async () => {
try {
const params = new URLSearchParams()
if (disk.name) params.set('device', disk.name)
if (disk.serial && disk.serial !== 'Unknown') params.set('serial', disk.serial)
const data = await fetchApi<{ observations: DiskObservation[] }>(`/api/storage/observations?${params.toString()}`)
setDiskObservations(data.observations || [])
} catch {
setDiskObservations([])
} finally {
setLoadingObservations(false)
}
}
// Fetch SMART JSON data from real test if available
const fetchSmartJson = async () => {
try {
const data = await fetchApi<{
has_data: boolean
data?: Record<string, unknown>
timestamp?: string
test_type?: string
}>(`/api/storage/smart/${disk.name}/latest`)
setSmartJsonData(data)
} catch {
setSmartJsonData({ has_data: false })
} finally {
setLoadingSmartJson(false)
}
}
// Run both in parallel
await Promise.all([fetchObservations(), fetchSmartJson()])
}
const formatObsDate = (iso: string) => {
if (!iso) return 'N/A'
try {
const d = new Date(iso)
const day = d.getDate().toString().padStart(2, '0')
const month = (d.getMonth() + 1).toString().padStart(2, '0')
const year = d.getFullYear()
const hours = d.getHours().toString().padStart(2, '0')
const mins = d.getMinutes().toString().padStart(2, '0')
return `${day}/${month}/${year} ${hours}:${mins}`
} catch { return iso }
}
const obsTypeLabel = (t: string) =>
({ smart_error: 'SMART Error', io_error: 'I/O Error', filesystem_error: 'Filesystem Error', zfs_pool_error: 'ZFS Pool Error', connection_error: 'Connection Error' }[t] || t)
const getStorageTypeBadge = (type: string) => {
const typeColors: Record<string, string> = {
pbs: "bg-purple-500/10 text-purple-500 border-purple-500/20",
dir: "bg-blue-500/10 text-blue-500 border-blue-500/20",
lvmthin: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
zfspool: "bg-green-500/10 text-green-500 border-green-500/20",
nfs: "bg-orange-500/10 text-orange-500 border-orange-500/20",
cifs: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
}
// Sprint 13: /proc/mounts reports `nfs4`, `cifs2`, `smb3`, `smbfs`,
// etc. PVE storage types are clean (`nfs`, `cifs`) but the kernel
// mount types carry version suffixes. Match the family so the
// Remote Mounts list shows the same colour as the matching PVE
// storage row instead of falling through to the grey default.
const lower = type.toLowerCase()
if (typeColors[lower]) return typeColors[lower]
if (lower.startsWith("nfs")) return typeColors.nfs
if (lower.startsWith("cifs") || lower.startsWith("smb")) return typeColors.cifs
return "bg-gray-500/10 text-gray-500 border-gray-500/20"
}
const getStatusIcon = (status: string) => {
switch (status.toLowerCase()) {
case "active":
case "online":
return <CheckCircle2 className="h-5 w-5 text-green-500" />
case "namespace_restricted":
return <CheckCircle2 className="h-5 w-5 text-blue-400" />
case "inactive":
case "offline":
return <Square className="h-5 w-5 text-gray-500" />
case "error":
case "failed":
return <AlertTriangle className="h-5 w-5 text-red-500" />
default:
return <CheckCircle2 className="h-5 w-5 text-gray-500" />
}
}
const getWearIndicator = (disk: DiskInfo): { value: number; label: string } | null => {
const diskType = getDiskType(disk.name, disk.rotation_rate)
if (diskType === "NVMe" && disk.percentage_used !== undefined && disk.percentage_used !== null) {
return { value: disk.percentage_used, label: "Percentage Used" }
}
if (diskType === "SSD") {
// Prioridad: Media Wearout Indicator > Wear Leveling Count > SSD Life Left
if (disk.media_wearout_indicator !== undefined && disk.media_wearout_indicator !== null) {
return { value: disk.media_wearout_indicator, label: "Media Wearout" }
}
if (disk.wear_leveling_count !== undefined && disk.wear_leveling_count !== null) {
return { value: disk.wear_leveling_count, label: "Wear Level" }
}
if (disk.ssd_life_left !== undefined && disk.ssd_life_left !== null) {
return { value: 100 - disk.ssd_life_left, label: "Life Used" }
}
}
return null
}
const getWearColor = (wearPercent: number): string => {
if (wearPercent <= 50) return "text-green-500"
if (wearPercent <= 80) return "text-yellow-500"
return "text-red-500"
}
const getEstimatedLifeRemaining = (disk: DiskInfo): string | null => {
const wearIndicator = getWearIndicator(disk)
if (!wearIndicator || !disk.power_on_hours || disk.power_on_hours === 0) {
return null
}
const wearPercent = wearIndicator.value
const hoursUsed = disk.power_on_hours
// If the drive reports zero wear we cannot extrapolate (division by zero).
// The drive is alive and healthy — return a friendlier label than "N/A",
// which users mistook for "the monitor is broken". A new drive can sit at
// 0% wear for hundreds of hours before the first measurable tick.
if (wearPercent === 0) {
return "No wear detected yet"
}
// Calcular horas totales estimadas: hoursUsed / (wearPercent / 100)
const totalEstimatedHours = hoursUsed / (wearPercent / 100)
const remainingHours = totalEstimatedHours - hoursUsed
// Convertir a años
const remainingYears = remainingHours / 8760 // 8760 horas en un año
if (remainingYears < 1) {
const remainingMonths = Math.round(remainingYears * 12)
return `~${remainingMonths} months`
}
return `~${remainingYears.toFixed(1)} years`
}
const getDiskHealthBreakdown = () => {
if (!storageData || !storageData.disks) {
return { normal: 0, warning: 0, critical: 0 }
}
let normal = 0
let warning = 0
let critical = 0
storageData.disks.forEach((disk) => {
if (disk.temperature === 0) {
// No temperature reading available — count as normal so a
// missing sensor doesn't inflate the warning count.
normal++
return
}
// Reuse the exact threshold lookup that the per-disk badge
// (`getTempColor`) uses, so the green / amber / red colour in
// the disk card and the "X normal, Y warning, Z critical" tally
// at the top of the page always agree.
//
// Previously this breakdown carried its own hardcoded ladder
// (HDD ≤45 normal, ≤55 warning, …) which was far stricter than
// the configurable defaults from `useDiskTempThresholds`
// (HDD warn 60, hot 65). A disk at 48 °C therefore showed a
// green badge but was counted as "warning" in the summary —
// exactly the case the user reported. Driving both from the
// same source (`dtThresholds`) means the user-tunable values
// under Settings → Health Monitor Thresholds → Disk temperature
// now apply to the breakdown as well, and the displayed colour
// is always the source of truth.
const diskType = getDiskType(disk.name, disk.rotation_rate)
const cls: keyof DiskTempMap =
diskType === "NVMe" ? "NVMe" : diskType === "SSD" ? "SSD" : "HDD"
const t = dtThresholds[cls]
if (disk.temperature >= t.hot) critical++
else if (disk.temperature >= t.warn) warning++
else normal++
})
return { normal, warning, critical }
}
const getDiskTypesBreakdown = () => {
if (!storageData || !storageData.disks) {
return { nvme: 0, ssd: 0, hdd: 0, usb: 0 }
}
let nvme = 0
let ssd = 0
let hdd = 0
let usb = 0
storageData.disks.forEach((disk) => {
if (disk.connection_type === 'usb') {
usb++
return
}
const diskType = getDiskType(disk.name, disk.rotation_rate)
if (diskType === "NVMe") nvme++
else if (diskType === "SSD") ssd++
else if (diskType === "HDD") hdd++
})
return { nvme, ssd, hdd, usb }
}
const getWearProgressColor = (wearPercent: number): string => {
if (wearPercent < 70) return "[&>div]:bg-blue-500"
if (wearPercent < 85) return "[&>div]:bg-yellow-500"
return "[&>div]:bg-red-500"
}
const getUsageColor = (percent: number): string => {
if (percent < 70) return "text-blue-500"
if (percent < 85) return "text-yellow-500"
if (percent < 95) return "text-orange-500"
return "text-red-500"
}
const diskHealthBreakdown = getDiskHealthBreakdown()
const diskTypesBreakdown = getDiskTypesBreakdown()
const localStorageTypes = ["dir", "lvmthin", "lvm", "zfspool", "btrfs"]
const remoteStorageTypes = ["pbs", "nfs", "cifs", "smb", "glusterfs", "iscsi", "iscsidirect", "rbd", "cephfs"]
const totalLocalUsed =
proxmoxStorage?.storage
.filter(
(storage) =>
storage &&
storage.name &&
storage.status === "active" &&
storage.total > 0 &&
storage.used >= 0 &&
storage.available >= 0 &&
localStorageTypes.includes(storage.type.toLowerCase()),
)
.reduce((sum, storage) => sum + storage.used, 0) || 0
const totalLocalCapacity =
proxmoxStorage?.storage
.filter(
(storage) =>
storage &&
storage.name &&
storage.status === "active" &&
storage.total > 0 &&
storage.used >= 0 &&
storage.available >= 0 &&
localStorageTypes.includes(storage.type.toLowerCase()),
)
.reduce((sum, storage) => sum + storage.total, 0) || 0
const localUsagePercent = totalLocalCapacity > 0 ? ((totalLocalUsed / totalLocalCapacity) * 100).toFixed(2) : "0.00"
const totalRemoteUsed =
proxmoxStorage?.storage
.filter(
(storage) =>
storage &&
storage.name &&
storage.status === "active" &&
storage.total > 0 &&
storage.used >= 0 &&
storage.available >= 0 &&
remoteStorageTypes.includes(storage.type.toLowerCase()),
)
.reduce((sum, storage) => sum + storage.used, 0) || 0
const totalRemoteCapacity =
proxmoxStorage?.storage
.filter(
(storage) =>
storage &&
storage.name &&
storage.status === "active" &&
storage.total > 0 &&
storage.used >= 0 &&
storage.available >= 0 &&
remoteStorageTypes.includes(storage.type.toLowerCase()),
)
.reduce((sum, storage) => sum + storage.total, 0) || 0
const remoteUsagePercent =
totalRemoteCapacity > 0 ? ((totalRemoteUsed / totalRemoteCapacity) * 100).toFixed(2) : "0.00"
const remoteStorageCount =
proxmoxStorage?.storage.filter(
(storage) =>
storage &&
storage.name &&
storage.status === "active" &&
remoteStorageTypes.includes(storage.type.toLowerCase()),
).length || 0
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<div className="relative">
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading storage data...</div>
<p className="text-xs text-muted-foreground">Scanning disks, partitions and storage pools</p>
</div>
)
}
if (!storageData || storageData.error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">Error loading storage data: {storageData?.error || "Unknown error"}</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Storage Summary */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
{/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */}
{(() => {
const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0)
const localPct = totalGB > 0 ? (totalLocalUsed / totalGB) * 100 : 0
const remotePct = totalGB > 0 ? (totalRemoteUsed / totalGB) * 100 : 0
const freeGB = Math.max(0, totalGB - totalLocalUsed - totalRemoteUsed)
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Storage Used</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{(() => {
const totalUsed = totalLocalUsed + totalRemoteUsed
const usedStr = formatStorage(totalUsed)
return (
<div className="flex items-end justify-between mb-3">
<div>
<span className="text-3xl font-bold leading-none">{usedStr.split(' ')[0]}</span>
<span className="text-base font-medium ml-1 text-muted-foreground">{usedStr.split(' ')[1]}</span>
</div>
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">{storageData.disk_count} disks</Badge>
</div>
)
})()}
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
<div style={{ width: `${localPct}%`, background: '#3b82f6' }} title={`Local ${formatStorage(totalLocalUsed)}`}></div>
<div style={{ width: `${remotePct}%`, background: '#06b6d4' }} title={`Remote ${formatStorage(totalRemoteUsed)}`}></div>
<div style={{ flex: 1, background: 'rgba(99,102,241,0.15)' }} title={`Free ${formatStorage(freeGB)}`}></div>
</div>
<div className="mt-2 space-y-1 text-sm">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-muted-foreground"><span className="w-1.5 h-1.5 rounded-full" style={{ background: '#3b82f6' }}></span>Local</span>
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalLocalUsed)}</span>
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-muted-foreground"><span className="w-1.5 h-1.5 rounded-full" style={{ background: '#06b6d4' }}></span>Remote</span>
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalRemoteUsed)}</span>
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-muted-foreground"><span className="w-1.5 h-1.5 rounded-full opacity-50" style={{ background: 'currentColor' }}></span>Free</span>
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(freeGB)}</span>
</div>
</div>
</CardContent>
</Card>
)
})()}
{/* ── Local Used (preview restyle: donut + mini-bars Used/Free) ── */}
{(() => {
const pct = Number.parseFloat(localUsagePercent)
const freeGB = Math.max(0, totalLocalCapacity - totalLocalUsed)
const stroke = pct >= 90 ? '#ef4444' : pct >= 75 ? '#f59e0b' : '#22c55e'
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Local Used</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
<circle cx="18" cy="18" r="15.9155" fill="none" stroke={stroke} strokeWidth="3"
strokeDasharray={`${pct} 100`} strokeLinecap="round"
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(pct)}%</text>
</svg>
<div className="flex-1 space-y-2">
<div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Used</span>
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalLocalUsed)}</span>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: stroke }}/>
</div>
</div>
<div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Free</span>
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(freeGB)}</span>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${100 - pct}%`, background: 'rgba(99,102,241,0.45)' }}/>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total</span>
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalLocalCapacity)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
})()}
{/* ── Remote Used (preview restyle: donut + mini-bars Used/Free) ── */}
{(() => {
const has = remoteStorageCount > 0
const pct = has ? Number.parseFloat(remoteUsagePercent) : 0
const freeGB = has ? Math.max(0, totalRemoteCapacity - totalRemoteUsed) : 0
const stroke = pct >= 90 ? '#ef4444' : pct >= 75 ? '#f59e0b' : '#22c55e'
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Remote Used</CardTitle>
<Archive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{has ? (
<div className="flex items-center gap-4">
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
<circle cx="18" cy="18" r="15.9155" fill="none" stroke={stroke} strokeWidth="3"
strokeDasharray={`${pct} 100`} strokeLinecap="round"
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(pct)}%</text>
</svg>
<div className="flex-1 space-y-2">
<div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Used</span>
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalRemoteUsed)}</span>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: stroke }}/>
</div>
</div>
<div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Free</span>
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(freeGB)}</span>
</div>
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${100 - pct}%`, background: 'rgba(99,102,241,0.45)' }}/>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total</span>
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalRemoteCapacity)}</span>
</div>
</div>
</div>
) : (
<div className="text-center py-4">
<div className="text-2xl font-bold text-muted-foreground">None</div>
<p className="text-xs text-muted-foreground mt-1">No remote storage</p>
</div>
)}
</CardContent>
</Card>
)
})()}
{/* ── Physical Disks (preview restyle: headline + type strip + health badge) ── */}
{(() => {
const total = Math.max(1, storageData.disk_count || 0)
const seg = 100 / total
const allHealthy = diskHealthBreakdown.warning === 0 && diskHealthBreakdown.critical === 0
const healthBadge = allHealthy
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20"> all healthy</Badge>
: diskHealthBreakdown.critical > 0
? <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">{diskHealthBreakdown.critical} critical</Badge>
: <Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">{diskHealthBreakdown.warning} warning</Badge>
const seg_purple = '#a855f7'
const seg_cyan = '#06b6d4'
const seg_blue = '#3b82f6'
const seg_orange = '#f97316'
const segments: Array<{ color: string }> = []
for (let i = 0; i < diskTypesBreakdown.nvme; i++) segments.push({ color: seg_purple })
for (let i = 0; i < diskTypesBreakdown.ssd; i++) segments.push({ color: seg_cyan })
for (let i = 0; i < diskTypesBreakdown.hdd; i++) segments.push({ color: seg_blue })
for (let i = 0; i < diskTypesBreakdown.usb; i++) segments.push({ color: seg_orange })
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Physical Disks</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-end justify-between mb-3">
<div>
<span className="text-3xl font-bold leading-none">{storageData.disk_count}</span>
<span className="text-base font-medium ml-1 text-muted-foreground">disks</span>
</div>
{healthBadge}
</div>
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
{segments.map((s, i) => (
<div key={i} style={{ width: `${seg}%`, background: s.color }}></div>
))}
</div>
<div className="mt-2 flex flex-wrap justify-between text-sm text-muted-foreground gap-x-2 gap-y-1">
{diskTypesBreakdown.nvme > 0 && <span className="flex items-center gap-1 whitespace-nowrap"><span className="w-1.5 h-1.5 rounded-full" style={{ background: seg_purple }}></span>{diskTypesBreakdown.nvme} NVMe</span>}
{diskTypesBreakdown.ssd > 0 && <span className="flex items-center gap-1 whitespace-nowrap"><span className="w-1.5 h-1.5 rounded-full" style={{ background: seg_cyan }}></span>{diskTypesBreakdown.ssd} SSD</span>}
{diskTypesBreakdown.hdd > 0 && <span className="flex items-center gap-1 whitespace-nowrap"><span className="w-1.5 h-1.5 rounded-full" style={{ background: seg_blue }}></span>{diskTypesBreakdown.hdd} HDD</span>}
{diskTypesBreakdown.usb > 0 && <span className="flex items-center gap-1 whitespace-nowrap"><span className="w-1.5 h-1.5 rounded-full" style={{ background: seg_orange }}></span>{diskTypesBreakdown.usb} USB</span>}
</div>
</CardContent>
</Card>
)
})()}
</div>
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Proxmox Storage
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{proxmoxStorage.storage
.filter((storage) => storage && storage.name && storage.used >= 0 && storage.available >= 0)
.sort((a, b) => a.name.localeCompare(b.name))
.map((storage) => {
// Check if storage is excluded from monitoring
const isExcluded = storage.excluded === true
const hasError = storage.status === "error" && !isExcluded
return (
<div
key={storage.name}
className={`border rounded-lg p-4 ${
hasError
? "border-red-500/50 bg-red-500/5"
: isExcluded
? "border-purple-500/30 bg-purple-500/5 opacity-75"
: ""
}`}
>
<div className="flex items-center justify-between mb-3">
{/* Desktop: Icon + Name + Badge tipo alineados horizontalmente */}
<div className="hidden md:flex items-center gap-3">
<Database className="h-5 w-5 text-muted-foreground" />
<h3 className="font-semibold text-lg">{storage.name}</h3>
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
{/* Sprint 13: hint that this PVE storage also
shows up below in Remote Mounts where the
user can inspect mount options + health.
Uses the default Badge size to match the
adjacent type / status badges — earlier
versions used text-[10px] which looked
shrunken next to them. */}
{/^(nfs|cifs|smb)/i.test(storage.type) && (
<Badge className="bg-cyan-500/10 text-cyan-400 border-cyan-500/20">
remote mount
</Badge>
)}
{isExcluded && (
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20">
excluded
</Badge>
)}
</div>
<div className="flex md:hidden items-center gap-2 flex-1">
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
{/^(nfs|cifs|smb)/i.test(storage.type) && (
<Badge className="bg-cyan-500/10 text-cyan-400 border-cyan-500/20">
remote
</Badge>
)}
<h3 className="font-semibold text-base flex-1 min-w-0 truncate">{storage.name}</h3>
{isExcluded ? (
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20 text-[10px]">
excluded
</Badge>
) : (
getStatusIcon(storage.status)
)}
</div>
{/* Desktop: Badge active + Porcentaje */}
<div className="hidden md:flex items-center gap-2">
<Badge
className={
isExcluded
? "bg-purple-500/10 text-purple-400 border-purple-500/20"
: storage.status === "active"
? "bg-green-500/10 text-green-500 border-green-500/20"
: storage.status === "namespace_restricted"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: storage.status === "error"
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
}
title={
storage.status === "namespace_restricted"
? "Storage reachable; datastore size hidden by ACL (e.g. PBS DatastoreAdmin on a single namespace)"
: undefined
}
>
{isExcluded
? "not monitored"
: storage.status === "namespace_restricted"
? "namespace-restricted"
: storage.status}
</Badge>
<span className="text-sm font-medium">{storage.percent}%</span>
</div>
</div>
<div className="space-y-2">
<Progress
value={storage.percent}
className={`h-2 ${
storage.percent > 90
? "[&>div]:bg-red-500"
: storage.percent > 75
? "[&>div]:bg-yellow-500"
: "[&>div]:bg-blue-500"
}`}
/>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Total</p>
<p className="font-medium">{formatStorage(storage.total)}</p>
</div>
<div>
<p className="text-muted-foreground">Used</p>
<p
className={`font-medium ${
storage.percent > 90
? "text-red-400"
: storage.percent > 75
? "text-yellow-400"
: "text-blue-400"
}`}
>
{formatStorage(storage.used)}
</p>
</div>
<div>
<p className="text-muted-foreground">Available</p>
<p className="font-medium text-green-400">{formatStorage(storage.available)}</p>
</div>
</div>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Sprint 13 — Remote Mounts (NFS/CIFS/SMB) detected on the
host. Renders only when at least one is present so a
standalone host with no shares doesn't see an empty card.
Stale mounts get a red bg + critical icon; read-only get
amber; healthy get a green dot. */}
{remoteMounts.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Remote Mounts
<Badge variant="outline" className="ml-2 text-[10px]">
{remoteMounts.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{remoteMounts
.slice()
.sort((a, b) => a.target.localeCompare(b.target))
.map((mount) => {
const isStale = mount.status === "stale"
const isReadonly = mount.status === "readonly"
const cardClasses = isStale
? "border-red-500/50 bg-red-500/5 sm:hover:bg-red-500/10"
: isReadonly
? "border-amber-500/40 bg-amber-500/5 sm:hover:bg-amber-500/10"
: "border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5"
return (
<div
key={mount.target}
onClick={() => setMountDetail(mount)}
className={`cursor-pointer border rounded-lg p-3 transition-colors ${cardClasses}`}
>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isStale ? "bg-red-500" : isReadonly ? "bg-amber-500" : "bg-green-500"
}`}
/>
<h3 className="font-mono text-sm truncate">{mount.target}</h3>
<Badge className={getStorageTypeBadge(mount.fstype)}>{mount.fstype}</Badge>
{/* Sprint 13.18: makes it explicit that the
row corresponds to an entry already in the
Proxmox Storage card above. Default size
keeps it visually consistent with the
adjacent type badge. */}
{mount.proxmox_managed && (
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">
managed by Proxmox
</Badge>
)}
{mount.readonly && (
<Badge className="bg-amber-500/10 text-amber-500 border-amber-500/20">
ro
</Badge>
)}
</div>
<Badge
className={
isStale
? "bg-red-500/10 text-red-500 border-red-500/20"
: isReadonly
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
}
>
{isStale ? "stale" : isReadonly ? "read-only" : "reachable"}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-2 truncate">
<span className="font-medium text-foreground">Source:</span>{" "}
<span className="font-mono">{mount.source || "—"}</span>
</div>
{isStale && mount.error && (
<p className="text-xs text-red-400 mt-2">{mount.error}</p>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Sprint 13.29: the "Remote Mounts (LXC)" card that previously
lived here was removed because the information was redundant
with the host card (the same NAS shows up twice) and a
Storage page is the wrong scope for per-CT details anyway.
LXC mount-points are now surfaced inside the LXC modal
(VMs & LXCs tab) where they belong contextually. The
backend helper `mount_monitor.scan_lxc_mounts()` is kept so
the health monitor can still alert on stale mounts inside
containers in the background. */}
{/* Sprint 13.19: remote mount detail modal.
Uses shadcn Dialog for the same typography (DialogTitle =
text-lg, DialogDescription = text-sm muted) and behaviour as
the disk details modal — earlier version was a hand-rolled
overlay with text-xs/text-[10px] all over and looked
shrunken next to the rest of the modals. */}
<Dialog open={!!mountDetail} onOpenChange={(open) => { if (!open) setMountDetail(null) }}>
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
{mountDetail && (() => {
const m = mountDetail
const isStale = m.status === "stale"
const isReadonly = m.status === "readonly"
const optionEntries = (m.options || "")
.split(",")
.filter(Boolean)
.map((opt) => {
const eq = opt.indexOf("=")
if (eq === -1) return { key: opt, value: null as string | null }
return { key: opt.slice(0, eq), value: opt.slice(eq + 1) }
})
const flags = optionEntries.filter((o) => o.value === null).map((o) => o.key)
const keyValues = optionEntries.filter((o) => o.value !== null) as Array<{ key: string; value: string }>
const fmtBytes = (b: number | null | undefined) => {
if (b == null) return "—"
const gb = b / 1024 ** 3
return formatStorage(gb)
}
const usedPct =
m.total_bytes && m.used_bytes != null && m.total_bytes > 0
? Math.round((m.used_bytes / m.total_bytes) * 100)
: null
return (
<>
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle className="flex items-center gap-2 flex-wrap">
<Database className="h-5 w-5 text-cyan-500" />
<span className="font-mono">{m.target}</span>
<Badge
className={
isStale
? "bg-red-500/10 text-red-500 border-red-500/20"
: isReadonly
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
}
>
{isStale ? "stale" : isReadonly ? "read-only" : "reachable"}
</Badge>
</DialogTitle>
<DialogDescription>
<span className="font-mono">{m.source || "—"}</span>
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6 overflow-auto space-y-5">
{/* Type + tags */}
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getStorageTypeBadge(m.fstype)}>{m.fstype}</Badge>
{m.proxmox_managed && (
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">
managed by Proxmox
</Badge>
)}
{m.lxc_id && (
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20">
CT {m.lxc_id}{m.lxc_name ? `: ${m.lxc_name}` : ""}
</Badge>
)}
{flags.map((f) => (
<Badge key={f} variant="outline" className="font-mono">
{f}
</Badge>
))}
</div>
{/* Capacity. df can hang on stale NFS so the backend
skips it and we render n/a here. Headers use the
same `<h4 className="font-semibold">` shape as
the disk-details modal in this same file (no
explicit text-sm override) so the typography
lines up — the body inherits text-base from the
Dialog content, not text-sm. */}
<div>
<h4 className="font-semibold mb-3">Capacity</h4>
{m.reachable && m.total_bytes ? (
<div className="space-y-2">
<Progress
value={usedPct ?? 0}
className={`h-2 ${
(usedPct ?? 0) > 90
? "[&>div]:bg-red-500"
: (usedPct ?? 0) > 75
? "[&>div]:bg-yellow-500"
: "[&>div]:bg-blue-500"
}`}
/>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Total</p>
<p className="font-medium">{fmtBytes(m.total_bytes)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Used</p>
<p className="font-medium">
{fmtBytes(m.used_bytes)} {usedPct != null && `(${usedPct}%)`}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Available</p>
<p className="font-medium">{fmtBytes(m.available_bytes)}</p>
</div>
</div>
</div>
) : (
<p className="text-muted-foreground">
{isStale ? "df skipped: mount is stale." : "n/a"}
</p>
)}
</div>
{/* Mount options grid — readable parse of the
key=value list from /proc/mounts so the user
doesn't have to scan a 200-char string. */}
{keyValues.length > 0 && (
<div>
<h4 className="font-semibold mb-3">Mount options</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1.5">
{keyValues.map((kv) => (
<div key={kv.key} className="flex items-baseline gap-2 min-w-0">
<span className="font-mono text-muted-foreground truncate">{kv.key}</span>
<span className="font-mono text-foreground truncate">= {kv.value}</span>
</div>
))}
</div>
</div>
)}
{/* Error — only renders when something is wrong. */}
{m.error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3">
<h4 className="font-semibold text-red-400 mb-2">Error</h4>
<p className="text-red-300 font-mono whitespace-pre-wrap break-all">
{m.error}
</p>
</div>
)}
</div>
</>
)
})()}
</DialogContent>
</Dialog>
{/* ZFS Pools */}
{storageData.zfs_pools && storageData.zfs_pools.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
ZFS Pools
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{storageData.zfs_pools.map((pool) => (
<div key={pool.name} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-lg">{pool.name}</h3>
{getHealthBadge(pool.health)}
</div>
{getHealthIcon(pool.health)}
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-sm text-muted-foreground">Size</p>
<p className="font-medium">{pool.size}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Allocated</p>
<p className="font-medium">{pool.allocated}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Free</p>
<p className="font-medium">{pool.free}</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Physical Disks (internal only) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive className="h-5 w-5" />
Physical Disks & SMART Status
</CardTitle>
</CardHeader>
<CardContent>
<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) => renderDiskCardV2(disk))}
</div>
</CardContent>
</Card>
{/* External Storage (USB) */}
{storageData.disks.filter(d => d.connection_type === 'usb').length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Usb className="h-5 w-5" />
External Storage (USB)
</CardTitle>
</CardHeader>
<CardContent>
<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) => renderDiskCardV2(disk))}
</div>
</CardContent>
</Card>
)}
{/* 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) => {
setDetailsOpen(open)
if (!open) {
setActiveModalTab("overview")
setSmartJsonData(null)
}
}}>
<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">
<DialogTitle className="flex items-center gap-2">
{selectedDisk?.connection_type === 'usb' ? (
<Usb className="h-5 w-5 text-orange-400" />
) : (
<HardDrive className="h-5 w-5" />
)}
Disk Details: /dev/{selectedDisk?.name}
{selectedDisk?.connection_type === 'usb' && (
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
)}
{selectedDisk?.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>
)}
</DialogTitle>
<DialogDescription>
{selectedDisk?.model !== "Unknown" ? selectedDisk?.model : "Physical disk"} - {selectedDisk?.size_formatted}
</DialogDescription>
</DialogHeader>
{/* Tab Navigation */}
<div className="flex border-b border-border px-6 overflow-x-auto">
<button
onClick={() => setActiveModalTab("overview")}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
activeModalTab === "overview"
? "border-blue-500 text-blue-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Info className="h-4 w-4" />
Overview
</button>
<button
onClick={() => setActiveModalTab("smart")}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
activeModalTab === "smart"
? "border-green-500 text-green-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Activity className="h-4 w-4" />
SMART
</button>
<button
onClick={() => setActiveModalTab("history")}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
activeModalTab === "history"
? "border-orange-500 text-orange-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Archive className="h-4 w-4" />
History
</button>
<button
onClick={() => setActiveModalTab("schedule")}
className={`flex items-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
activeModalTab === "schedule"
? "border-purple-500 text-purple-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Clock className="h-4 w-4" />
Schedule
</button>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{selectedDisk && activeModalTab === "overview" && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Model</p>
<p className="font-medium">{selectedDisk.model}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Serial Number</p>
<p className="font-medium">{selectedDisk.serial?.replace(/\\x[0-9a-fA-F]{2}/g, '') || 'Unknown'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Capacity</p>
<p className="font-medium">{selectedDisk.size_formatted}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Health Status</p>
<div className="flex items-center gap-2 mt-1">
{getHealthBadge(selectedDisk.health)}
{(selectedDisk.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" />
{selectedDisk.observations_count} obs.
</Badge>
)}
</div>
</div>
</div>
{/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */}
{(() => {
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
lifeRemaining = 100 - wearUsed
estimatedLife = getEstimatedLifeRemaining(selectedDisk) || ''
if (selectedDisk.total_lbas_written && selectedDisk.total_lbas_written > 0) {
const tb = selectedDisk.total_lbas_written / 1024
dataWritten = tb >= 1 ? `${tb.toFixed(2)} TB` : `${selectedDisk.total_lbas_written.toFixed(2)} GB`
}
}
// --- Step 2: SMART test JSON — primary for SSD, supplement for NVMe ---
if (smartJsonData?.has_data && smartJsonData.data) {
const data = smartJsonData.data as Record<string, unknown>
const nvmeHealth = (data?.nvme_smart_health_information_log || data) as Record<string, unknown>
// Available spare (only from SMART/NVMe data)
if (spare === undefined) {
spare = (nvmeHealth?.avail_spare ?? nvmeHealth?.available_spare) as number | undefined
}
// Data written — use SMART JSON if DiskInfo didn't provide it
if (!dataWritten) {
const ataAttrs = data?.ata_smart_attributes as { table?: Array<{ id: number; name: string; value: number; raw?: { value: number } }> }
const table = ataAttrs?.table || []
const lbasAttr = table.find(a =>
a.name?.toLowerCase().includes('total_lbas_written') ||
a.name?.toLowerCase().includes('writes_gib') ||
a.name?.toLowerCase().includes('lifetime_writes') ||
a.id === 241
)
if (lbasAttr && lbasAttr.raw?.value) {
const n = (lbasAttr.name || '').toLowerCase()
const tb = (n.includes('gib') || n.includes('_gb') || n.includes('writes_gib'))
? lbasAttr.raw.value / 1024
: (lbasAttr.raw.value * 512) / (1024 ** 4)
dataWritten = tb >= 1 ? `${tb.toFixed(2)} TB` : `${(tb * 1024).toFixed(2)} GB`
} else if (nvmeHealth?.data_units_written) {
const tb = ((nvmeHealth.data_units_written as number) * 512000) / (1024 ** 4)
dataWritten = tb >= 1 ? `${tb.toFixed(2)} TB` : `${(tb * 1024).toFixed(2)} GB`
}
}
// Wear/life — use SMART JSON only if DiskInfo didn't provide it (SSD without backend support)
if (lifeRemaining === null) {
const ataAttrs = data?.ata_smart_attributes as { table?: Array<{ id: number; name: string; value: number; raw?: { value: number } }> }
const table = ataAttrs?.table || []
const wearAttr = table.find(a =>
a.name?.toLowerCase().includes('wear_leveling') ||
a.name?.toLowerCase().includes('media_wearout') ||
a.name?.toLowerCase().includes('ssd_life_left') ||
a.id === 177 || a.id === 231
)
const nvmeIsPresent = nvmeHealth?.percent_used !== undefined || nvmeHealth?.percentage_used !== undefined
if (wearAttr) {
lifeRemaining = (wearAttr.id === 230) ? (100 - wearAttr.value) : wearAttr.value
} else if (nvmeIsPresent) {
lifeRemaining = 100 - ((nvmeHealth.percent_used ?? nvmeHealth.percentage_used ?? 0) as number)
}
if (lifeRemaining !== null) {
wearUsed = 100 - lifeRemaining
const poh = selectedDisk.power_on_hours || 0
if (lifeRemaining > 0 && lifeRemaining < 100 && poh > 0) {
const used = 100 - lifeRemaining
if (used > 0) {
const ry = ((poh / (used / 100)) - poh) / (24 * 365)
estimatedLife = ry >= 1 ? `~${ry.toFixed(1)} years` : `~${(ry * 12).toFixed(0)} months`
}
}
}
}
}
// --- Only render if we have meaningful wear data ---
if (wearUsed === null && lifeRemaining === null) return null
// Sprint 14 honest-data fix: a `percent_used == 0` from
// firmwares like the WD CL SN720 isn't real wear data —
// the drive simply hasn't started ticking. We don't want
// to assert "100% life remaining" in that case. Show
// only Data Written, since that's the one number we
// know we can trust for these drives.
const hasReportedWear = (wearUsed !== null && wearUsed > 0)
if (!hasReportedWear) {
if (!dataWritten) return null
return (
<div className="border-t pt-4">
<h4 className="font-semibold mb-3 flex items-center gap-2">
Wear & Lifetime
</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-muted-foreground">Data Written</p>
<p className="text-sm font-medium">{dataWritten}</p>
</div>
</div>
</div>
)
}
const lifeColor = lifeRemaining !== null
? (lifeRemaining >= 50 ? '#22c55e' : lifeRemaining >= 20 ? '#eab308' : '#ef4444')
: '#6b7280'
return (
<div className="border-t pt-4">
<h4 className="font-semibold mb-3 flex items-center gap-2">
Wear & Lifetime
{smartJsonData?.has_data && !wi && (
<Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5">Real Test</Badge>
)}
</h4>
<div className="flex gap-5 items-start">
{lifeRemaining !== null && (
<div className="flex flex-col items-center gap-1 flex-shrink-0">
<svg width="88" height="88" viewBox="0 0 88 88">
<circle cx="44" cy="44" r="35" fill="none" stroke={lifeColor} strokeWidth="6"
strokeDasharray={`${lifeRemaining * 2.199} 219.9`}
strokeLinecap="round" transform="rotate(-90 44 44)" />
<text x="44" y="40" textAnchor="middle" fill={lifeColor} fontSize="20" fontWeight="700">{lifeRemaining}%</text>
<text x="44" y="56" textAnchor="middle" fill="currentColor" fontSize="12" className="text-muted-foreground">life</text>
</svg>
</div>
)}
<div className="flex-1 space-y-3 min-w-0">
{/*
Hide the "Wear" bar and "Est. Life" entirely when the
drive firmware reports zero wear (some NVMe families
like the WD SN720 don't tick percentage_used until
significant wear is reached). The 100% life ring + the
Avail. Spare and Data Written numbers are enough to
convey "drive is healthy without any reportable wear
data" — repeating "0%" three times is just visual noise.
*/}
{wearUsed !== null && wearUsed > 0 && (
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs text-muted-foreground">Wear</p>
<p className="text-sm font-medium text-blue-400">{wearUsed}%</p>
</div>
<Progress value={wearUsed} className="h-2 [&>div]:bg-blue-500" />
</div>
)}
<div className="grid grid-cols-2 gap-3">
{estimatedLife && wearUsed !== null && wearUsed > 0 && (
<div>
<p className="text-xs text-muted-foreground">Est. Life</p>
<p className="text-sm font-medium">{estimatedLife}</p>
</div>
)}
{dataWritten && (
<div>
<p className="text-xs text-muted-foreground">Data Written</p>
<p className="text-sm font-medium">{dataWritten}</p>
</div>
)}
{spare !== undefined && (
<div>
<p className="text-xs text-muted-foreground">Avail. Spare</p>
<p className={`text-sm font-medium ${spare < 20 ? 'text-red-400' : 'text-blue-400'}`}>{spare}%</p>
</div>
)}
</div>
</div>
</div>
</div>
)
})()}
<div className="border-t pt-4">
<h4 className="font-semibold mb-3">SMART Attributes</h4>
{/*
Sprint 14: temperature lives in its own full-width card
with an inline 1-hour mini chart. The remaining attributes
flow below in the same 2-col grid as before.
*/}
{selectedDisk.connection_type !== 'usb' && (
<div className="mb-4">
<DiskTemperatureCard
diskName={selectedDisk.name}
liveTemperature={selectedDisk.temperature}
diskType={getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate).label}
onOpenDetail={selectedDisk.temperature > 0 ? () => setTempHistoryDisk(selectedDisk) : undefined}
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Power On Hours</p>
<p className="font-medium">
{selectedDisk.power_on_hours && selectedDisk.power_on_hours > 0
? `${selectedDisk.power_on_hours.toLocaleString()}h (${formatHours(selectedDisk.power_on_hours)})`
: "N/A"}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Rotation Rate</p>
<p className="font-medium">{formatRotationRate(selectedDisk.rotation_rate)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Power Cycles</p>
<p className="font-medium">
{selectedDisk.power_cycles && selectedDisk.power_cycles > 0
? selectedDisk.power_cycles.toLocaleString()
: "N/A"}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">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>
<p className="text-sm text-muted-foreground">Reallocated Sectors</p>
<p className={`font-medium flex items-center gap-1.5 ${
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}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Pending Sectors</p>
<p className={`font-medium flex items-center gap-1.5 ${
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}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">CRC Errors</p>
<p className={`font-medium flex items-center gap-1.5 ${
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}
</p>
</div>
{/* USB drives lose the chart card; show plain temperature here. */}
{selectedDisk.connection_type === 'usb' && (
<div>
<p className="text-sm text-muted-foreground">Temperature</p>
<p className={`font-medium ${getTempColor(selectedDisk.temperature, selectedDisk.name, selectedDisk.rotation_rate)}`}>
{selectedDisk.temperature > 0 ? `${selectedDisk.temperature}°C` : "N/A"}
</p>
</div>
)}
</div>
</div>
{/* OLD SMART Test Data section removed — now unified in Wear & Lifetime above */}
{false && (
<div className="border-t pt-4">
<h4 className="font-semibold mb-3 flex items-center gap-2">
<Activity className="h-4 w-4 text-green-400" />
{(() => {
// Check if this is SSD without Proxmox wear data - show as "Wear & Lifetime"
const isNvme = selectedDisk.name?.includes('nvme')
const hasProxmoxWear = getWearIndicator(selectedDisk) !== null
if (!isNvme && !hasProxmoxWear && smartJsonData?.has_data) {
return 'Wear & Lifetime'
}
return 'SMART Test Data'
})()}
{smartJsonData?.has_data && (
<Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5">
Real Test
</Badge>
)}
</h4>
{loadingSmartJson ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<div className="h-4 w-4 rounded-full border-2 border-transparent border-t-green-400 animate-spin" />
Loading SMART test data...
</div>
) : smartJsonData?.has_data && smartJsonData.data ? (
<div className="space-y-3">
{/* SSD/NVMe Life Estimation from JSON - Uniform style */}
{(() => {
const data = smartJsonData.data as Record<string, unknown>
const ataAttrs = data?.ata_smart_attributes as { table?: Array<{ id: number; name: string; value: number; raw?: { value: number } }> }
const table = ataAttrs?.table || []
// Look for wear-related attributes for SSD
const wearAttr = table.find(a =>
a.name?.toLowerCase().includes('wear_leveling') ||
a.name?.toLowerCase().includes('media_wearout') ||
a.name?.toLowerCase().includes('percent_lifetime') ||
a.name?.toLowerCase().includes('ssd_life_left') ||
a.id === 177 || a.id === 231 || a.id === 233
)
// Look for total LBAs written
const lbasAttr = table.find(a =>
a.name?.toLowerCase().includes('total_lbas_written') ||
a.id === 241
)
// Look for power on hours from SMART data
const pohAttr = table.find(a =>
a.name?.toLowerCase().includes('power_on_hours') ||
a.id === 9
)
// For NVMe, check nvme_smart_health_information_log
const nvmeHealth = data?.nvme_smart_health_information_log as Record<string, unknown>
// Calculate data written
let dataWrittenTB = 0
let dataWrittenLabel = ''
if (lbasAttr && lbasAttr.raw?.value) {
dataWrittenTB = (lbasAttr.raw.value * 512) / (1024 ** 4)
dataWrittenLabel = dataWrittenTB >= 1
? `${dataWrittenTB.toFixed(2)} TB`
: `${(dataWrittenTB * 1024).toFixed(2)} GB`
} else if (nvmeHealth?.data_units_written) {
const units = nvmeHealth.data_units_written as number
dataWrittenTB = (units * 512000) / (1024 ** 4)
dataWrittenLabel = dataWrittenTB >= 1
? `${dataWrittenTB.toFixed(2)} TB`
: `${(dataWrittenTB * 1024).toFixed(2)} GB`
}
// Get wear percentage (life remaining %)
let wearPercent: number | null = null
let wearLabel = 'Life Remaining'
if (wearAttr) {
if (wearAttr.id === 230) {
// Media_Wearout_Indicator (WD/SanDisk): value = endurance used %
wearPercent = 100 - wearAttr.value
} else {
// Standard: value = normalized life remaining %
wearPercent = wearAttr.value
}
wearLabel = 'Life Remaining'
} else if (nvmeHealth?.percentage_used !== undefined) {
wearPercent = 100 - (nvmeHealth.percentage_used as number)
wearLabel = 'Life Remaining'
}
// Calculate estimated life remaining
let estimatedLife = ''
const powerOnHours = pohAttr?.raw?.value || selectedDisk.power_on_hours || 0
if (wearPercent !== null && wearPercent > 0 && wearPercent < 100 && powerOnHours > 0) {
const usedPercent = 100 - wearPercent
if (usedPercent > 0) {
const totalEstimatedHours = powerOnHours / (usedPercent / 100)
const remainingHours = totalEstimatedHours - powerOnHours
const remainingYears = remainingHours / (24 * 365)
if (remainingYears >= 1) {
estimatedLife = `~${remainingYears.toFixed(1)} years`
} else {
const remainingMonths = remainingYears * 12
estimatedLife = `~${remainingMonths.toFixed(0)} months`
}
}
}
// Available spare for NVMe
const availableSpare = nvmeHealth?.available_spare as number | undefined
if (wearPercent !== null || dataWrittenLabel) {
return (
<>
{/* Wear Progress Bar - Blue style matching NVMe */}
{wearPercent !== null && (
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm text-muted-foreground">{wearLabel}</p>
<p className="font-medium text-blue-400">
{wearPercent}%
</p>
</div>
<Progress
value={wearPercent}
className={`h-2 ${wearPercent < 20 ? '[&>div]:bg-red-500' : '[&>div]:bg-blue-500'}`}
/>
</div>
)}
{/* Stats Grid - Same layout as NVMe Wear & Lifetime */}
<div className="grid grid-cols-2 gap-4">
{estimatedLife && (
<div>
<p className="text-sm text-muted-foreground">Estimated Life Remaining</p>
<p className="font-medium">{estimatedLife}</p>
</div>
)}
{dataWrittenLabel && (
<div>
<p className="text-sm text-muted-foreground">Total Data Written</p>
<p className="font-medium">{dataWrittenLabel}</p>
</div>
)}
{availableSpare !== undefined && (
<div>
<p className="text-sm text-muted-foreground">Available Spare</p>
<p className={`font-medium ${availableSpare < 20 ? 'text-red-400' : availableSpare < 50 ? 'text-yellow-400' : 'text-green-400'}`}>
{availableSpare}%
</p>
</div>
)}
</div>
</>
)
}
return null
})()}
</div>
) : (
<div className="text-sm text-muted-foreground">
<p>No SMART test data available for this disk.</p>
<p className="text-xs mt-1">Run a SMART test in the SMART Test tab to get detailed health information.</p>
</div>
)}
</div>
)}
{/* Observations Section */}
{(diskObservations.length > 0 || loadingObservations) && (
<div className="border-t pt-4">
<h4 className="font-semibold mb-2 flex items-center gap-2">
<Info className="h-4 w-4 text-blue-400" />
Observations
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">
{diskObservations.length}
</Badge>
</h4>
<p className="text-xs text-muted-foreground mb-3">
The following observations have been recorded for this disk:
</p>
{loadingObservations ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<div className="h-4 w-4 rounded-full border-2 border-transparent border-t-blue-400 animate-spin" />
Loading observations...
</div>
) : (
<div className="space-y-3">
{diskObservations.map((obs) => (
<div
key={obs.id}
className="rounded-lg border p-3 text-sm bg-blue-500/5 border-blue-500/20"
>
{/* Header with type badge — always blue.
The earlier red/blue split-by-severity was
confusing here because the Observations
panel is a *history* view, not a live
alert; the severity already reaches the
user through the notification channels.
The card just records what happened. */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<Badge className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400 border-blue-500/20">
{obsTypeLabel(obs.error_type)}
</Badge>
</div>
{/* Error message - responsive text wrap */}
<p className="text-xs whitespace-pre-wrap break-words opacity-90 font-mono leading-relaxed mb-1">
{obs.raw_message}
</p>
{translateAtaError(obs.raw_message) && (
<p className="text-xs italic opacity-75 mb-3 break-words">
{translateAtaError(obs.raw_message)}
</p>
)}
{/* Dates - stacked on mobile, inline on desktop */}
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3 text-[10px] text-muted-foreground border-t border-white/5 pt-2">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3 flex-shrink-0" />
<span className="break-words">First: {formatObsDate(obs.first_occurrence)}</span>
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3 flex-shrink-0" />
<span className="break-words">Last: {formatObsDate(obs.last_occurrence)}</span>
</span>
</div>
{/* Occurrences count */}
<div className="text-[10px] text-muted-foreground mt-1">
Occurrences: <span className="font-medium text-foreground">{obs.occurrence_count}</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* SMART Test Tab */}
{selectedDisk && activeModalTab === "smart" && (
<SmartTestTab disk={selectedDisk} observations={diskObservations} lastTestDate={smartJsonData?.timestamp || undefined} />
)}
{/* History Tab */}
{selectedDisk && activeModalTab === "history" && (
<HistoryTab disk={selectedDisk} />
)}
{/* Schedule Tab */}
{selectedDisk && activeModalTab === "schedule" && (
<ScheduleTab disk={selectedDisk} />
)}
</div>
</DialogContent>
</Dialog>
{tempHistoryDisk && (
<DiskTemperatureDetailModal
open={!!tempHistoryDisk}
onOpenChange={(o) => { if (!o) setTempHistoryDisk(null) }}
diskName={tempHistoryDisk.name}
diskModel={tempHistoryDisk.model}
liveTemperature={tempHistoryDisk.temperature}
diskType={getDiskTypeBadge(tempHistoryDisk.name, tempHistoryDisk.rotation_rate).label}
/>
)}
</div>
)
}
// Generate SMART Report HTML and open in new window (same pattern as Lynis/Latency reports)
interface DiskTempHistoryPoint { timestamp: number; value: number; min?: number; max?: number }
interface DiskTempHistoryPayload { data: DiskTempHistoryPoint[]; stats: { min: number; max: number; avg: number; current: number } }
// The report wants the broadest temperature history possible, but the
// `month` bucket (2h granularity) returns <2 points on freshly-deployed
// hosts where data only spans ~1 hour. Cascade through coarser → finer
// timeframes and use the first one that yields a renderable chart.
async function fetchTempHistoryForReport(diskName: string): Promise<DiskTempHistoryPayload | undefined> {
for (const tf of ['month', 'week', 'day', 'hour']) {
try {
const result = await fetchApi<DiskTempHistoryPayload>(
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=${tf}`,
)
if (result?.data && result.data.length >= 2) return result
} catch {
/* try next */
}
}
return undefined
}
function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttributes: SmartAttribute[], observations: DiskObservation[] = [], lastTestDate?: string, targetWindow?: Window, isHistorical = false, tempHistory?: DiskTempHistoryPayload) {
const now = new Date().toLocaleString()
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
const reportId = `SMART-${Date.now().toString(36).toUpperCase()}`
// --- Enriched device fields from smart_data ---
const sd = testStatus.smart_data
const modelFamily = sd?.model_family || ''
const formFactor = sd?.form_factor || ''
const physBlockSize = sd?.physical_block_size ?? 512
const trimSupported = sd?.trim_supported ?? false
const sataVersion = sd?.sata_version || ''
const ifaceSpeed = sd?.interface_speed || ''
const pollingShort = sd?.polling_minutes_short
const pollingExt = sd?.polling_minutes_extended
const errorLogCount = sd?.error_log_count ?? 0
const selfTestHistory = sd?.self_test_history || []
// SMR detection (WD Red without Plus, known SMR families)
const isSMR = modelFamily.toLowerCase().includes('smr') ||
/WD (Red|Blue|Green) \d/.test(modelFamily) ||
/WDC WD\d{4}[EZ]/.test(disk.model || '')
// Seagate proprietary Raw_Read_Error_Rate detection
const isSeagate = modelFamily.toLowerCase().includes('seagate') ||
modelFamily.toLowerCase().includes('barracuda') ||
modelFamily.toLowerCase().includes('ironwolf') ||
(disk.model || '').startsWith('ST')
// Test age warning
let testAgeDays = 0
let testAgeWarning = ''
if (lastTestDate) {
const testDate = new Date(lastTestDate)
testAgeDays = Math.floor((Date.now() - testDate.getTime()) / (1000 * 60 * 60 * 24))
if (testAgeDays > 90) {
testAgeWarning = `This report is based on a SMART test performed ${testAgeDays} days ago (${testDate.toLocaleDateString()}). Disk health may have changed since then. We recommend running a new SMART test for up-to-date results.`
}
}
// Determine disk type (SAS detected via backend flag or connection_type)
const isSasDisk = sd?.is_sas === true || disk.connection_type === 'sas'
let diskType = "HDD"
if (disk.name.startsWith("nvme")) {
diskType = "NVMe"
} else if (isSasDisk) {
diskType = "SAS"
} else if (!disk.rotation_rate || disk.rotation_rate === 0) {
diskType = "SSD"
}
// Health status styling
const healthStatus = testStatus.smart_status || (testStatus.smart_data?.smart_status) || 'unknown'
const isHealthy = healthStatus.toLowerCase() === 'passed'
const healthColor = isHealthy ? '#16a34a' : healthStatus.toLowerCase() === 'failed' ? '#dc2626' : '#ca8a04'
const healthLabel = isHealthy ? 'PASSED' : healthStatus.toUpperCase()
// Format power on time — force 'en' locale for consistent comma separator
const fmtNum = (n: number) => n.toLocaleString('en-US')
const powerOnHours = disk.power_on_hours || testStatus.smart_data?.power_on_hours || 0
const powerOnDays = Math.round(powerOnHours / 24)
const powerOnYears = Math.floor(powerOnHours / 8760)
const powerOnRemainingDays = Math.floor((powerOnHours % 8760) / 24)
const powerOnFormatted = powerOnYears > 0
? `${powerOnYears}y ${powerOnRemainingDays}d (${fmtNum(powerOnHours)}h)`
: `${powerOnDays}d (${fmtNum(powerOnHours)}h)`
// Build attributes table - format differs for NVMe vs SATA
const isNvmeForTable = diskType === 'NVMe'
// Explanations for NVMe metrics
const nvmeExplanations: Record<string, string> = {
'Critical Warning': 'Active alert flags from the NVMe controller. Any non-zero value requires immediate investigation.',
'Temperature': 'Composite temperature reported by the controller. Sustained high temps cause thermal throttling and reduce NAND lifespan.',
'Temperature Sensor 1': 'Primary temperature sensor, usually the NAND flash. Most representative of flash health.',
'Temperature Sensor 2': 'Secondary sensor, often the controller die. Typically runs hotter than Sensor 1.',
'Temperature Sensor 3': 'Tertiary sensor, if present. Location varies by manufacturer.',
'Available Spare': 'Percentage of spare NAND blocks remaining for bad-block replacement. Alert triggers below threshold.',
'Available Spare Threshold': 'Manufacturer-set minimum for Available Spare. Below this, the drive flags a critical warning.',
'Percentage Used': "Drive's own estimate of endurance consumed based on actual vs. rated write cycles. 100% = rated TBW reached; drive may continue working beyond this.",
'Percent Used': "Drive's own estimate of endurance consumed based on actual vs. rated write cycles. 100% = rated TBW reached; drive may continue working beyond this.",
'Media Errors': 'Unrecoverable read/write errors on the NAND flash. Any non-zero value indicates permanent cell damage. Growing count = replace soon.',
'Media and Data Integrity Errors': 'Unrecoverable errors detected by the controller. Non-zero means data corruption risk.',
'Unsafe Shutdowns': 'Power losses without proper flush/shutdown. Very high counts risk metadata corruption and firmware issues.',
'Power Cycles': 'Total on/off cycles. Frequent cycling increases connector and capacitor wear.',
'Power On Hours': 'Total cumulative hours the drive has been powered on since manufacture.',
'Data Units Read': 'Total data read in 512KB units. Multiply by 512,000 for bytes. Useful for calculating daily read workload.',
'Data Units Written': 'Total data written in 512KB units. Compare with TBW rating to estimate remaining endurance.',
'Host Read Commands': 'Total read commands issued by the host. High ratio vs. write commands indicates read-heavy workload.',
'Host Write Commands': 'Total write commands issued by the host. Includes filesystem metadata writes.',
'Controller Busy Time': 'Total minutes the controller spent processing I/O commands. High values indicate sustained heavy workload.',
'Error Log Entries': 'Number of entries in the error information log. Often includes benign self-test artifacts; cross-check with Media Errors.',
'Error Information Log Entries': 'Number of entries in the error information log. Often includes benign self-test artifacts.',
'Warning Temp Time': 'Total minutes spent above the warning temperature threshold. Causes performance throttling. Zero is ideal.',
'Critical Temp Time': 'Total minutes spent above the critical temperature threshold. Drive may shut down to prevent damage. Should always be zero.',
'Warning Composite Temperature Time': 'Total minutes the composite temperature exceeded the warning threshold.',
'Critical Composite Temperature Time': 'Total minutes the composite temperature exceeded the critical threshold. Must be zero.',
'Thermal Management T1 Trans Count': 'Number of times the drive entered light thermal throttling (T1). Indicates cooling issues.',
'Thermal Management T2 Trans Count': 'Number of times the drive entered heavy thermal throttling (T2). Significant performance impact.',
'Thermal Management T1 Total Time': 'Total seconds spent in light thermal throttling. Indicates sustained cooling problems.',
'Thermal Management T2 Total Time': 'Total seconds spent in heavy thermal throttling. Severe performance degradation.',
}
// Explanations for SATA/SSD attributes — covers HDD, SSD, and mixed-use attributes
const sataExplanations: Record<string, string> = {
// === Read/Write Errors ===
'Raw Read Error Rate': 'Hardware read errors detected. High raw values on Seagate/Samsung drives are normal (proprietary formula where VALUE, not raw, matters).',
'Write Error Rate': 'Errors encountered during write operations. Growing count may indicate head or media issues.',
'Multi Zone Error Rate': 'Errors when writing to multi-zone regions. Manufacturer-specific; rising trend is concerning.',
'Soft Read Error Rate': 'Read errors corrected by firmware without data loss. High values may indicate degrading media.',
'Read Error Retry Rate': 'Number of read retries needed. Occasional retries are normal; persistent growth indicates wear.',
'Reported Uncorrect': 'Errors that ECC could not correct. Any non-zero value means data was lost or unreadable.',
'Reported Uncorrectable Errors': 'Errors that ECC could not correct. Non-zero = data loss risk.',
// === Reallocated / Pending / Offline ===
'Reallocated Sector Ct': 'Bad sectors replaced by spare sectors from the reserve pool. Growing count = drive degradation.',
'Reallocated Sector Count': 'Bad sectors replaced by spare sectors. Growing count indicates drive degradation.',
'Reallocated Sectors': 'Bad sectors replaced by spare sectors. Growing count indicates drive degradation.',
'Retired Block Count': 'NAND blocks retired due to wear or failure (SSD). Similar to Reallocated Sector Count for HDDs.',
'Reallocated Event Count': 'Number of remap operations performed. Each event means a bad sector was replaced.',
'Current Pending Sector': 'Unstable sectors waiting to be remapped on next write. May resolve or become permanently reallocated.',
'Current Pending Sector Count': 'Unstable sectors waiting to be remapped on next write. Non-zero warrants monitoring.',
'Pending Sectors': 'Sectors waiting to be remapped. May resolve or become reallocated.',
'Offline Uncorrectable': 'Sectors that failed during offline scan and could not be corrected. Indicates potential data loss.',
'Offline Uncorrectable Sector Count': 'Uncorrectable sectors found during background scan. Data on these sectors is lost.',
// === Temperature ===
'Temperature': 'Current drive temperature. Sustained high temps accelerate wear and reduce lifespan.',
'Temperature Celsius': 'Current drive temperature in Celsius. HDDs: keep below 45°C; SSDs: below 60°C.',
'Airflow Temperature Cel': 'Temperature measured by the airflow sensor. Usually slightly lower than the main temp sensor.',
'Temperature Case': 'Temperature of the drive casing. Useful for monitoring enclosure ventilation.',
'Temperature Internal': 'Internal temperature sensor. May read higher than case temperature.',
// === Power & Uptime ===
'Power On Hours': 'Total cumulative hours the drive has been powered on. Used to estimate age and plan replacements.',
'Power On Hours and Msec': 'Total powered-on time with millisecond precision.',
'Power Cycle Count': 'Total number of complete power on/off cycles. Frequent cycling stresses electronics.',
'Power Off Retract Count': 'Times the heads were retracted due to power loss (HDD). High values indicate unstable power supply.',
'Unexpected Power Loss Ct': 'Unexpected power losses (SSD). Can cause metadata corruption if write-cache was active.',
'Unsafe Shutdown Count': 'Power losses without proper shutdown (SSD). High values risk firmware corruption.',
'Start Stop Count': 'Spindle motor start/stop cycles (HDD). Each cycle causes mechanical wear.',
// === Mechanical (HDD-specific) ===
'Spin Up Time': 'Time for platters to reach full operating speed (HDD). Increasing values may indicate motor bearing wear.',
'Spin Retry Count': 'Failed attempts to spin up the motor (HDD). Non-zero usually indicates power supply or motor issues.',
'Calibration Retry Count': 'Number of head calibration retries (HDD). Non-zero may indicate mechanical issues.',
'Seek Error Rate': 'Errors during head positioning (HDD). High raw values on Seagate are often normal (proprietary formula).',
'Seek Time Performance': 'Average seek operation performance (HDD). Declining values suggest mechanical degradation.',
'Load Cycle Count': 'Head load/unload cycles (HDD). Rated for 300K-600K cycles on most drives.',
'Load Unload Cycle Count': 'Head load/unload cycles (HDD). Each cycle causes micro-wear on the ramp mechanism.',
'Head Flying Hours': 'Hours the read/write heads have been positioned over the platters (HDD).',
'High Fly Writes': 'Writes where the head flew higher than expected (HDD). Data may not be written correctly.',
'G Sense Error Rate': 'Shock/vibration events detected by the accelerometer (HDD). High values indicate physical disturbance.',
'Disk Shift': 'Distance the disk has shifted from its original position (HDD). Temperature or shock-related.',
'Loaded Hours': 'Hours spent with heads loaded over the platters (HDD).',
'Load In Time': 'Time of the head loading process. Manufacturer-specific diagnostic metric.',
'Torque Amplification Count': 'Times the drive needed extra torque to spin up. May indicate stiction or motor issues.',
'Flying Height': 'Head-to-platter distance during operation (HDD). Critical for read/write reliability.',
'Load Friction': 'Friction detected during head loading (HDD). Increasing values suggest ramp mechanism wear.',
'Load Unload Retry Count': 'Failed head load/unload attempts (HDD). Non-zero indicates mechanical issues.',
// === Interface Errors ===
'UDMA CRC Error Count': 'Data transfer checksum errors on the SATA cable. Usually caused by a bad cable, loose connection, or port issue.',
'CRC Errors': 'Interface communication errors. Usually caused by cable or connection issues.',
'CRC Error Count': 'Data transfer checksum errors. Replace the SATA cable if this value grows.',
'Command Timeout': 'Commands that took too long and timed out. May indicate controller or connection issues.',
'Interface CRC Error Count': 'CRC errors on the interface link. Cable or connector problem.',
// === ECC & Data Integrity ===
'Hardware ECC Recovered': 'Read errors corrected by hardware ECC. Non-zero is normal; rapid growth warrants attention.',
'ECC Error Rate': 'Rate of ECC-corrected errors. Proprietary formula; VALUE matters more than raw count.',
'End to End Error': 'Data corruption detected between the controller cache and host interface. Should always be zero.',
'End to End Error Detection Count': 'Number of parity errors in the data path. Non-zero indicates controller issues.',
// === SSD Wear & Endurance ===
'Wear Leveling Count': 'Average erase cycles per NAND block (SSD). Lower VALUE = more wear consumed.',
'Wear Range Delta': 'Difference between most-worn and least-worn blocks (SSD). High values indicate uneven wear.',
'Media Wearout Indicator': 'Intel SSD life remaining estimate. Starts at 100, decreases to 0 as endurance is consumed.',
'SSD Life Left': 'Estimated remaining SSD lifespan percentage based on NAND wear.',
'Percent Lifetime Remain': 'Estimated remaining lifespan percentage. 100 = new; 0 = end of rated life.',
'Percent Lifetime Used': 'Percentage of rated endurance consumed. Inverse of Percent Lifetime Remain.',
'Available Reservd Space': 'Remaining spare blocks as a percentage of total reserves (SSD). Similar to NVMe Available Spare.',
'Available Reserved Space': 'Remaining spare blocks as a percentage (SSD). Low values reduce the drive\'s ability to handle bad blocks.',
'Used Rsvd Blk Cnt Tot': 'Total reserve blocks consumed for bad-block replacement (SSD). Growing = aging.',
'Used Reserved Block Count': 'Number of reserve blocks used for bad-block replacement (SSD).',
'Unused Rsvd Blk Cnt Tot': 'Remaining reserve blocks available (SSD). Zero = no more bad-block replacement possible.',
'Unused Reserve Block Count': 'Reserve blocks still available for bad-block replacement (SSD).',
'Program Fail Cnt Total': 'Total NAND program (write) failures (SSD). Non-zero indicates flash cell degradation.',
'Program Fail Count': 'NAND write failures (SSD). Growing count means flash cells are wearing out.',
'Program Fail Count Chip': 'Program failures at chip level (SSD). Non-zero indicates NAND degradation.',
'Erase Fail Count': 'NAND erase operation failures (SSD). Non-zero indicates severe flash wear.',
'Erase Fail Count Total': 'Total NAND erase failures (SSD). Combined with Program Fail Count shows overall NAND health.',
'Erase Fail Count Chip': 'Erase failures at chip level (SSD). Non-zero = NAND degradation.',
'Runtime Bad Block': 'Bad blocks discovered during normal operation (SSD). Different from factory-mapped bad blocks.',
'Runtime Bad Blocks': 'Blocks that failed during use (SSD). Growing count = flash wearing out.',
// === Data Volume ===
'Total LBAs Written': 'Total logical block addresses written. Multiply by 512 bytes for total data volume.',
'Total LBAs Read': 'Total logical block addresses read. Useful for calculating daily workload.',
'Lifetime Writes GiB': 'Total data written in GiB over the drive\'s lifetime.',
'Lifetime Reads GiB': 'Total data read in GiB over the drive\'s lifetime.',
'Total Writes GiB': 'Total data written in GiB. Compare with TBW rating for endurance estimate.',
'Total Reads GiB': 'Total data read in GiB.',
'NAND Writes GiB': 'Raw NAND writes in GiB. Higher than host writes due to write amplification.',
'Host Writes 32MiB': 'Total data written by the host in 32MiB units.',
'Host Reads 32MiB': 'Total data read by the host in 32MiB units.',
'Host Writes MiB': 'Total data written by the host in MiB.',
'Host Reads MiB': 'Total data read by the host in MiB.',
'NAND GB Written TLC': 'Total data written to TLC NAND cells in GB. Includes write amplification overhead.',
'NAND GiB Written': 'Total NAND writes in GiB. Higher than host writes due to write amplification and garbage collection.',
// === SSD-Specific Advanced ===
'Ave Block Erase Count': 'Average number of erase cycles per NAND block (SSD). Drives are typically rated for 3K-100K cycles.',
'Average Erase Count': 'Average erase cycles per block. Compare with rated endurance for remaining life estimate.',
'Max Erase Count': 'Maximum erase cycles on any single block. Large gap with average indicates uneven wear.',
'Total Erase Count': 'Sum of all erase cycles across all blocks. Overall NAND write volume indicator.',
'Power Loss Cap Test': 'Result of the power-loss protection capacitor self-test (SSD). Failed = risk of data loss on power failure.',
'Power Loss Protection': 'Status of the power-loss protection mechanism. Enterprise SSDs use capacitors to flush cache on power loss.',
'Successful RAIN Recov Cnt': 'Successful recoveries using RAIN (Redundant Array of Independent NAND). Shows NAND parity is working.',
'SSD Erase Fail Count': 'Total erase failures across the SSD. Indicates overall NAND degradation.',
'SSD Program Fail Count': 'Total write failures across the SSD. Indicates flash cell reliability issues.',
// === Throughput ===
'Throughput Performance': 'Overall throughput performance rating (HDD). Declining values indicate degradation.',
// === Other / Vendor-specific ===
'Unknown Attribute': 'Vendor-specific attribute not defined in the SMART standard. Check manufacturer documentation.',
'Free Fall Sensor': 'Free-fall events detected (laptop HDD). The heads are parked to prevent damage during drops.',
}
// Explanations for SAS/SCSI metrics
const sasExplanations: Record<string, string> = {
'Grown Defect List': 'Sectors remapped due to defects found during operation. Equivalent to Reallocated Sectors on SATA. Growing count = drive degradation.',
'Read Errors Corrected': 'Read errors corrected by ECC. Normal for enterprise drives under heavy workload — only uncorrected errors are critical.',
'Read ECC Fast': 'Errors corrected by fast (on-the-fly) ECC during read operations. Normal in SAS drives.',
'Read ECC Delayed': 'Errors requiring delayed (offline) ECC correction during reads. Non-zero is acceptable but should not grow rapidly.',
'Read Uncorrected Errors': 'Read errors that ECC could not correct. Non-zero means data was lost or unreadable. Critical metric.',
'Read Data Processed': 'Total data read by the drive. Useful for calculating daily workload.',
'Write Errors Corrected': 'Write errors corrected by ECC. Normal for enterprise drives.',
'Write Uncorrected Errors': 'Write errors that ECC could not correct. Non-zero = potential data loss. Critical.',
'Write Data Processed': 'Total data written to the drive. Useful for workload analysis.',
'Verify Errors Corrected': 'Verification errors corrected during background verify operations.',
'Verify Uncorrected Errors': 'Verify errors that could not be corrected. Non-zero indicates media degradation.',
'Non-Medium Errors': 'Controller/bus errors not related to the media itself. High count may indicate backplane or cable issues.',
'Temperature': 'Current drive temperature. Enterprise SAS drives tolerate up to 55-60°C under sustained load.',
'Power On Hours': 'Total hours the drive has been powered on. Enterprise drives are rated for 24/7 operation.',
'Start-Stop Cycles': 'Motor start/stop cycles. Enterprise SAS drives are rated for 50,000+ cycles.',
'Load-Unload Cycles': 'Head load/unload cycles. Enterprise drives are rated for 600,000+ cycles.',
'Background Scan Status': 'Status of the SCSI background media scan. Runs continuously to detect surface defects.',
}
const getAttrExplanation = (name: string, diskKind: string): string => {
const cleanName = name.replace(/_/g, ' ')
if (diskKind === 'NVMe') {
return nvmeExplanations[cleanName] || nvmeExplanations[name] || ''
}
if (diskKind === 'SAS') {
return sasExplanations[cleanName] || sasExplanations[name] || ''
}
return sataExplanations[cleanName] || sataExplanations[name] || ''
}
// SAS and NVMe use simplified table format (Metric | Value | Status)
const useSimpleTable = isNvmeForTable || isSasDisk
const attributeRows = smartAttributes.map((attr, i) => {
const statusColor = attr.status === 'ok' ? '#16a34a' : attr.status === 'warning' ? '#ca8a04' : '#dc2626'
const statusBg = attr.status === 'ok' ? '#16a34a15' : attr.status === 'warning' ? '#ca8a0415' : '#dc262615'
const explanation = getAttrExplanation(attr.name, diskType)
const explainRow = explanation
? `<tr class="attr-explain-row"><td colspan="${useSimpleTable ? '3' : '7'}" style="padding:0 4px 8px;border-bottom:1px solid #f1f5f9;"><div style="font-size:10px;color:#64748b;line-height:1.4;">${explanation}</div></td></tr>`
: ''
if (useSimpleTable) {
// NVMe/SAS format: Metric | Value | Status
const displayValue = isSasDisk ? attr.raw_value : attr.value
return `
<tr>
<td class="col-name" style="font-weight:500;${explanation ? 'border-bottom:none;padding-bottom:2px;' : ''}">${attr.name}</td>
<td style="text-align:center;font-family:monospace;${explanation ? 'border-bottom:none;' : ''}">${displayValue}</td>
<td style="${explanation ? 'border-bottom:none;' : ''}"><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()}</span></td>
</tr>
${explainRow}`
} else {
// SATA format: ID | Attribute | Val | Worst | Thr | Raw | Status
return `
<tr>
<td style="font-weight:600;${explanation ? 'border-bottom:none;padding-bottom:2px;' : ''}">${attr.id}</td>
<td class="col-name" style="font-weight:500;${explanation ? 'border-bottom:none;padding-bottom:2px;' : ''}">${attr.name.replace(/_/g, ' ')}</td>
<td style="text-align:center;${explanation ? 'border-bottom:none;' : ''}">${attr.value}</td>
<td style="text-align:center;${explanation ? 'border-bottom:none;' : ''}">${attr.worst}</td>
<td style="text-align:center;${explanation ? 'border-bottom:none;' : ''}">${attr.threshold}</td>
<td class="col-raw" style="${explanation ? 'border-bottom:none;' : ''}">${attr.raw_value}</td>
<td style="${explanation ? 'border-bottom:none;' : ''}"><span class="f-tag" style="background:${statusBg};color:${statusColor}">${attr.status === 'ok' ? 'OK' : attr.status.toUpperCase()}</span></td>
</tr>
${explainRow}`
}
}).join('')
// Critical attributes to highlight
const criticalAttrs = smartAttributes.filter(a => a.status !== 'ok')
const hasCritical = criticalAttrs.length > 0
// Temperature color and threshold strings for the printable report —
// both pulled from the user-configurable backend cache so the report
// prints whatever the operator set in Settings.
const _reportThresholds = getDiskTempThresholdsSync(diskType)
const getTempColorForReport = (temp: number): string => {
if (temp <= 0) return '#94a3b8' // gray for N/A
if (temp >= _reportThresholds.hot) return '#dc2626'
if (temp >= _reportThresholds.warn) return '#ca8a04'
return '#16a34a'
}
// Temperature thresholds for display
const tempThresholds = {
optimal: `<${_reportThresholds.warn}°C`,
warning: `${_reportThresholds.warn}-${_reportThresholds.hot - 1}°C`,
critical: `${_reportThresholds.hot}°C`,
}
const isNvmeDisk = diskType === 'NVMe'
// NVMe Wear & Lifetime data. Sprint 14 fix: the previous code used
// `?? 0` / `?? 100` as fallbacks, which made the report invent
// "100% Life Remaining" + "100% Available Spare" for drives that
// simply don't report those metrics (some early WDC SN720, some
// Samsung OEM, etc.). The dashboard modal already hides its wear
// section in that case — we mirror the same gating here so the
// printable report doesn't lie.
const nvmePercentUsedRaw = testStatus.smart_data?.nvme_raw?.percent_used ?? disk.percentage_used
const nvmeAvailSpareRaw = testStatus.smart_data?.nvme_raw?.avail_spare
// Sprint 14 honest-data fix (refined): only render the full Wear &
// Lifetime block when the firmware has actually started ticking
// percent_used. Drives like the WD CL SN720 expose `percent_used: 0`
// until significant wear is reached — treating that as "100% life
// remaining" is misleading. In that case we fall back to a minimal
// Data-Written-only block (handled separately below).
const hasNvmeWearData = (
typeof nvmePercentUsedRaw === 'number' && nvmePercentUsedRaw > 0
)
const nvmePercentUsed = nvmePercentUsedRaw ?? 0
const nvmeAvailSpare = nvmeAvailSpareRaw ?? 100
const nvmeDataWritten = testStatus.smart_data?.nvme_raw?.data_units_written ?? 0
// Data units are in 512KB blocks, convert to TB
const nvmeDataWrittenTB = (nvmeDataWritten * 512 * 1024) / (1024 * 1024 * 1024 * 1024)
// Calculate estimated life remaining for NVMe
let nvmeEstimatedLife = 'N/A'
if (nvmePercentUsed > 0 && disk.power_on_hours && disk.power_on_hours > 0) {
const totalEstimatedHours = disk.power_on_hours / (nvmePercentUsed / 100)
const remainingHours = totalEstimatedHours - disk.power_on_hours
const remainingYears = remainingHours / (24 * 365)
if (remainingYears >= 1) {
nvmeEstimatedLife = `~${remainingYears.toFixed(1)} years`
} else if (remainingHours >= 24) {
nvmeEstimatedLife = `~${Math.floor(remainingHours / 24)} days`
} else {
nvmeEstimatedLife = `~${Math.floor(remainingHours)} hours`
}
} else if (nvmePercentUsed === 0) {
nvmeEstimatedLife = 'Excellent'
}
// Wear color based on percentage
const getWearColorHex = (pct: number): string => {
if (pct <= 50) return '#16a34a' // green
if (pct <= 80) return '#ca8a04' // yellow
return '#dc2626' // red
}
// Life remaining color (inverse)
const getLifeColorHex = (pct: number): string => {
const remaining = 100 - pct
if (remaining >= 50) return '#16a34a' // green
if (remaining >= 20) return '#ca8a04' // yellow
return '#dc2626' // red
}
// Build recommendations
const recommendations: string[] = []
if (isHealthy) {
recommendations.push('<div class="rec-item rec-ok"><div class="rec-icon">&#10003;</div><div><strong>Disk is Healthy</strong><p>All SMART attributes are within normal ranges. Continue regular monitoring.</p></div></div>')
} else {
recommendations.push('<div class="rec-item rec-critical"><div class="rec-icon">&#10007;</div><div><strong>Critical: Disk Health Issue Detected</strong><p>SMART has reported a health issue. Backup all data immediately and plan for disk replacement.</p></div></div>')
}
if ((disk.reallocated_sectors ?? 0) > 0) {
recommendations.push(`<div class="rec-item rec-warn"><div class="rec-icon">&#9888;</div><div><strong>Reallocated Sectors Detected (${disk.reallocated_sectors})</strong><p>The disk has bad sectors that have been remapped. Monitor closely and consider replacement if count increases.</p></div></div>`)
}
if ((disk.pending_sectors ?? 0) > 0) {
recommendations.push(`<div class="rec-item rec-warn"><div class="rec-icon">&#9888;</div><div><strong>Pending Sectors (${disk.pending_sectors})</strong><p>There are sectors waiting to be reallocated. This may indicate impending failure.</p></div></div>`)
}
if (disk.temperature > 55 && diskType === 'HDD') {
recommendations.push(`<div class="rec-item rec-warn"><div class="rec-icon">&#9888;</div><div><strong>High Temperature (${disk.temperature}°C)</strong><p>HDD is running hot. Improve case airflow or add cooling.</p></div></div>`)
} else if (disk.temperature > 70 && diskType === 'SSD') {
recommendations.push(`<div class="rec-item rec-warn"><div class="rec-icon">&#9888;</div><div><strong>High Temperature (${disk.temperature}°C)</strong><p>SSD is running hot. Check airflow around the drive.</p></div></div>`)
} else if (disk.temperature > 80 && diskType === 'NVMe') {
recommendations.push(`<div class="rec-item rec-warn"><div class="rec-icon">&#9888;</div><div><strong>High Temperature (${disk.temperature}°C)</strong><p>NVMe is overheating. Consider adding a heatsink or improving case airflow.</p></div></div>`)
}
// NVMe critical warning
if (diskType === 'NVMe') {
const critWarnVal = testStatus.smart_data?.nvme_raw?.critical_warning ?? 0
const mediaErrVal = testStatus.smart_data?.nvme_raw?.media_errors ?? 0
const unsafeVal = testStatus.smart_data?.nvme_raw?.unsafe_shutdowns ?? 0
if (critWarnVal !== 0) {
recommendations.push(`<div class="rec-item rec-critical"><div class="rec-icon">&#10007;</div><div><strong>NVMe Critical Warning Active (0x${critWarnVal.toString(16).toUpperCase()})</strong><p>The NVMe controller has raised an alert flag. Back up data immediately and investigate further.</p></div></div>`)
}
if (mediaErrVal > 0) {
recommendations.push(`<div class="rec-item rec-critical"><div class="rec-icon">&#10007;</div><div><strong>NVMe Media Errors Detected (${mediaErrVal})</strong><p>Unrecoverable errors in NAND flash cells. Any non-zero value indicates physical flash damage. Back up data and plan for replacement.</p></div></div>`)
}
if (unsafeVal > 200) {
recommendations.push(`<div class="rec-item rec-warn"><div class="rec-icon">&#9888;</div><div><strong>High Unsafe Shutdown Count (${unsafeVal})</strong><p>Frequent power losses without proper shutdown increase the risk of firmware corruption. Ensure stable power supply or use a UPS.</p></div></div>`)
}
}
// Seagate Raw_Read_Error_Rate note
if (isSeagate) {
const hasRawReadAttr = smartAttributes.some(a => a.name === 'Raw_Read_Error_Rate' || a.id === 1)
if (hasRawReadAttr) {
recommendations.push('<div class="rec-item rec-info"><div class="rec-icon">&#9432;</div><div><strong>Seagate Raw_Read_Error_Rate — Normal Behavior</strong><p>Seagate drives report very large raw values for attribute #1 (Raw_Read_Error_Rate). This is expected and uses a proprietary formula — a high raw number does NOT indicate errors. Only the normalized value (column Val) matters, and it should remain at 100.</p></div></div>')
}
}
// SMR disk note
if (isSMR) {
recommendations.push('<div class="rec-item rec-info"><div class="rec-icon">&#9432;</div><div><strong>SMR Drive Detected — Write Limitations</strong><p>This appears to be a Shingled Magnetic Recording (SMR) disk. SMR drives have slower random-write performance and may stall during heavy mixed workloads. They are suitable for sequential workloads (backups, archives) but not recommended as primary Proxmox storage or ZFS vdevs.</p></div></div>')
}
if (recommendations.length === 1 && isHealthy) {
recommendations.push('<div class="rec-item rec-info"><div class="rec-icon">&#9432;</div><div><strong>Regular Maintenance</strong><p>Schedule periodic extended SMART tests (monthly) to catch issues early.</p></div></div>')
recommendations.push('<div class="rec-item rec-info"><div class="rec-icon">&#9432;</div><div><strong>Backup Strategy</strong><p>Ensure critical data is backed up regularly regardless of disk health status.</p></div></div>')
}
// Build observations HTML separately to avoid nested template literal issues
let observationsHtml = ''
if (observations.length > 0) {
const totalOccurrences = observations.reduce((sum, o) => sum + o.occurrence_count, 0)
// Group observations by error type
const groupedObs: Record<string, DiskObservation[]> = {}
observations.forEach(obs => {
const type = obs.error_type || 'unknown'
if (!groupedObs[type]) groupedObs[type] = []
groupedObs[type].push(obs)
})
let groupsHtml = ''
Object.entries(groupedObs).forEach(([type, obsList]) => {
const typeLabel = type === 'io_error' ? 'I/O Errors' : type === 'smart_error' ? 'SMART Errors' : type === 'filesystem_error' ? 'Filesystem Errors' : type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
const groupOccurrences = obsList.reduce((sum, o) => sum + o.occurrence_count, 0)
let obsItemsHtml = ''
obsList.forEach(obs => {
// Use blue (info) as base color for all observations
const infoColor = '#3b82f6'
const infoBg = '#3b82f615'
// Severity badge color based on actual severity
const severityBadgeColor = obs.severity === 'critical' ? '#dc2626' : obs.severity === 'warning' ? '#ca8a04' : '#3b82f6'
const severityLabel = obs.severity ? obs.severity.charAt(0).toUpperCase() + obs.severity.slice(1) : 'Info'
const firstDate = obs.first_occurrence ? new Date(obs.first_occurrence).toLocaleString() : 'N/A'
const lastDate = obs.last_occurrence ? new Date(obs.last_occurrence).toLocaleString() : 'N/A'
const dismissedBadge = obs.dismissed ? '<span style="background:#16a34a20;color:#16a34a;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:4px;">Dismissed</span>' : ''
const errorTypeLabel = type === 'io_error' ? 'I/O Error' : type === 'smart_error' ? 'SMART Error' : type === 'filesystem_error' ? 'Filesystem Error' : type.replace(/_/g, ' ')
obsItemsHtml += `
<div style="background:${infoBg};border:1px solid ${infoColor}30;border-radius:8px;padding:16px;">
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px;">
<span style="background:${infoColor}20;color:${infoColor};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${errorTypeLabel}</span>
<span style="background:${severityBadgeColor}20;color:${severityBadgeColor};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${severityLabel}</span>
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">ID: #${obs.id}</span>
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">Occurrences: <strong>${obs.occurrence_count}</strong></span>
${dismissedBadge}
</div>
<div style="margin-bottom:10px;">
<div style="font-size:10px;color:#475569;margin-bottom:4px;">Error Signature:</div>
<div style="font-family:monospace;font-size:11px;color:#1e293b;background:#f1f5f9;padding:8px;border-radius:4px;word-break:break-all;">${obs.error_signature}</div>
</div>
<div style="margin-bottom:12px;">
<div style="font-size:10px;color:#475569;margin-bottom:4px;">Raw Message:</div>
<div style="font-family:monospace;font-size:11px;color:#1e293b;background:#f8fafc;padding:10px;border-radius:4px;white-space:pre-wrap;word-break:break-all;max-height:120px;overflow-y:auto;">${obs.raw_message || 'N/A'}</div>
${translateAtaError(obs.raw_message || '') ? `<div style="font-size:11px;color:#475569;font-style:italic;margin-top:6px;padding-left:4px;">↳ ${translateAtaError(obs.raw_message || '')}</div>` : ''}
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(140px, 1fr));gap:10px;font-size:11px;padding-top:10px;border-top:1px solid ${infoColor}20;">
<div>
<span style="color:#475569;">Device:</span>
<strong style="color:#1e293b;margin-left:4px;">${obs.device_name || disk.name}</strong>
</div>
<div>
<span style="color:#475569;">Serial:</span>
<strong style="color:#1e293b;margin-left:4px;">${obs.serial || disk.serial || 'N/A'}</strong>
</div>
<div>
<span style="color:#475569;">Model:</span>
<strong style="color:#1e293b;margin-left:4px;">${obs.model || disk.model || 'N/A'}</strong>
</div>
<div>
<span style="color:#475569;">First Seen:</span>
<strong style="color:#1e293b;margin-left:4px;">${firstDate}</strong>
</div>
<div>
<span style="color:#475569;">Last Seen:</span>
<strong style="color:#1e293b;margin-left:4px;">${lastDate}</strong>
</div>
</div>
</div>
`
})
groupsHtml += `
<div style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #e2e8f0;">
<span style="font-weight:600;color:#1e293b;">${typeLabel}</span>
<span style="background:#64748b15;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">${obsList.length} unique, ${groupOccurrences} total</span>
</div>
<div style="display:flex;flex-direction:column;gap:12px;">
${obsItemsHtml}
</div>
</div>
`
})
const obsSecNum = isNvmeDisk ? '6' : '5'
observationsHtml = `
<!-- ${obsSecNum}. Observations & Events -->
<div class="section">
<div class="section-title">${obsSecNum}. Observations & Events (${observations.length} recorded, ${totalOccurrences} total occurrences)</div>
<p style="color:#475569;font-size:12px;margin-bottom:16px;">The following events have been detected and logged for this disk. These observations may indicate potential issues that require attention.</p>
${groupsHtml}
</div>
`
}
// Per-disk temperature history chart (Sprint 14). Rendered as inline
// SVG so it survives the print-to-PDF path. Only emits markup when the
// backend has actually sampled this disk; otherwise the section is
// omitted entirely (no point printing an empty card).
let temperatureChartHtml = ''
if (tempHistory && tempHistory.data && tempHistory.data.length >= 2) {
const points = tempHistory.data
const stats = tempHistory.stats
const W = 720, H = 200
const padL = 38, padR = 14, padT = 16, padB = 28
const innerW = W - padL - padR
const innerH = H - padT - padB
const ts0 = points[0].timestamp
const ts1 = points[points.length - 1].timestamp
const span = Math.max(1, ts1 - ts0)
const vals = points.map(p => p.value)
const dataMin = Math.min(...vals)
const dataMax = Math.max(...vals)
// Pad the y-domain a couple of degrees on each side so the line
// doesn't sit flush against the chart border.
const yMin = Math.max(0, Math.floor(dataMin - 3))
const yMax = Math.ceil(dataMax + 3)
const yRange = Math.max(1, yMax - yMin)
const xFor = (t: number) => padL + ((t - ts0) / span) * innerW
const yFor = (v: number) => padT + (1 - (v - yMin) / yRange) * innerH
// Threshold reference lines pulled from the user-configurable
// backend cache. `getDiskTempThresholdsSync` reads the in-memory
// map populated by `useDiskTempThresholds` mounted on the parent
// component — no extra fetch in the print flow.
const _dt = getDiskTempThresholdsSync(diskType)
const warnAt = _dt.warn
const hotAt = _dt.hot
const linePath = points.map((p, i) => {
const cmd = i === 0 ? 'M' : 'L'
return `${cmd}${xFor(p.timestamp).toFixed(1)},${yFor(p.value).toFixed(1)}`
}).join(' ')
// Area fill below the line (closing back along the bottom).
const areaPath = `${linePath} L${xFor(ts1).toFixed(1)},${(padT + innerH).toFixed(1)} L${xFor(ts0).toFixed(1)},${(padT + innerH).toFixed(1)} Z`
const formatXLabel = (ts: number) => {
const d = new Date(ts * 1000)
if (span <= 86400 * 2) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
// Y axis ticks — 4 evenly spaced labels.
const yTicks: number[] = []
for (let i = 0; i <= 4; i++) {
yTicks.push(yMin + (yRange * i) / 4)
}
// X axis ticks — start, mid, end.
const xTicks = [ts0, ts0 + span / 2, ts1]
// Per the user's preference the report chart is blue rather than
// colour-coded. Threshold bands and reference lines below still use
// the warn/hot palette so a hot stretch is visible without changing
// the line itself.
const lineColor = '#2563eb'
const samples = points.length
// Threshold band y-coords (clamped to chart area).
const yWarnBand = Math.max(padT, yFor(hotAt))
const yHotTop = padT
const yHotHeight = Math.max(0, yWarnBand - yHotTop)
const yMidTop = Math.max(padT, yFor(hotAt))
const yMidBottom = Math.min(padT + innerH, yFor(warnAt))
const yMidHeight = Math.max(0, yMidBottom - yMidTop)
temperatureChartHtml = `
<div style="margin-top:14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px 16px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:8px;">
<div>
<div style="font-size:11px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.04em;">Temperature history</div>
<div style="font-size:10px;color:#64748b;margin-top:2px;">${samples} samples · ${formatXLabel(ts0)}${formatXLabel(ts1)}</div>
</div>
<div style="display:flex;gap:14px;font-size:11px;">
<div><span style="color:#64748b;">Min</span> <strong style="color:#16a34a;">${stats.min}°C</strong></div>
<div><span style="color:#64748b;">Avg</span> <strong style="color:#1e293b;">${stats.avg}°C</strong></div>
<div><span style="color:#64748b;">Max</span> <strong style="color:#dc2626;">${stats.max}°C</strong></div>
</div>
</div>
<svg viewBox="0 0 ${W} ${H}" width="100%" preserveAspectRatio="none" style="display:block;max-height:200px;">
<!-- threshold bands -->
${yHotHeight > 0 ? `<rect x="${padL}" y="${yHotTop}" width="${innerW}" height="${yHotHeight}" fill="#fee2e2" opacity="0.55"/>` : ''}
${yMidHeight > 0 ? `<rect x="${padL}" y="${yMidTop}" width="${innerW}" height="${yMidHeight}" fill="#fef3c7" opacity="0.55"/>` : ''}
<!-- chart frame -->
<rect x="${padL}" y="${padT}" width="${innerW}" height="${innerH}" fill="none" stroke="#cbd5e1" stroke-width="1"/>
<!-- y grid + labels -->
${yTicks.map(t => {
const y = yFor(t).toFixed(1)
return `<line x1="${padL}" y1="${y}" x2="${padL + innerW}" y2="${y}" stroke="#e2e8f0" stroke-width="0.6" stroke-dasharray="2,3"/>` +
`<text x="${padL - 5}" y="${y}" text-anchor="end" dominant-baseline="middle" font-size="9" fill="#64748b">${Math.round(t)}°</text>`
}).join('')}
<!-- x labels -->
${xTicks.map(t => {
const x = xFor(t).toFixed(1)
return `<text x="${x}" y="${(padT + innerH + 16).toFixed(1)}" text-anchor="middle" font-size="9" fill="#64748b">${formatXLabel(t)}</text>`
}).join('')}
<!-- threshold reference lines -->
${warnAt > yMin && warnAt < yMax ? `<line x1="${padL}" y1="${yFor(warnAt).toFixed(1)}" x2="${padL + innerW}" y2="${yFor(warnAt).toFixed(1)}" stroke="#ca8a04" stroke-width="0.7" stroke-dasharray="3,2"/>` : ''}
${hotAt > yMin && hotAt < yMax ? `<line x1="${padL}" y1="${yFor(hotAt).toFixed(1)}" x2="${padL + innerW}" y2="${yFor(hotAt).toFixed(1)}" stroke="#dc2626" stroke-width="0.7" stroke-dasharray="3,2"/>` : ''}
<!-- area + line -->
<path d="${areaPath}" fill="${lineColor}" fill-opacity="0.12"/>
<path d="${linePath}" fill="none" stroke="${lineColor}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>
<div style="font-size:9px;color:#94a3b8;margin-top:4px;">Bands: amber = warn (≥${warnAt}°C), red = critical (≥${hotAt}°C). Sampled every 60s.</div>
</div>`
}
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SMART Health Report - /dev/${disk.name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
@page { margin: 10mm; size: A4; }
/* === SCREEN: responsive layout === */
@media screen {
body { max-width: 1000px; margin: 0 auto; padding: 24px 32px; padding-top: 64px; overflow-x: hidden; }
}
@media screen and (max-width: 640px) {
body { padding: 16px; padding-top: 64px; }
.grid-4 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr; }
.rpt-header { flex-direction: column; gap: 12px; align-items: flex-start; }
.rpt-header-right { text-align: left; }
.exec-box { flex-wrap: wrap; }
.card-c .card-value { font-size: 16px; }
}
/* === PRINT: force desktop A4 layout from any device === */
@media print {
html, body { margin: 0 !important; padding: 0 !important; width: 100% !important; max-width: none !important; }
.no-print { display: none !important; }
.top-bar { display: none !important; }
.page-break { page-break-before: always; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { font-size: 11px; padding-top: 0 !important; }
/* Force desktop grid layout regardless of viewport */
.grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr !important; }
.grid-3 { grid-template-columns: 1fr 1fr 1fr !important; }
.grid-2 { grid-template-columns: 1fr 1fr !important; }
.rpt-header { flex-direction: row !important; align-items: center !important; }
.rpt-header-right { text-align: right !important; }
.exec-box { flex-wrap: nowrap !important; }
.card-c .card-value { font-size: 20px !important; }
/* Page break control */
.section { page-break-inside: avoid; break-inside: avoid; margin-bottom: 15px; }
.exec-box { page-break-inside: avoid; break-inside: avoid; }
.card { page-break-inside: avoid; break-inside: avoid; }
.grid-2, .grid-3, .grid-4 { page-break-inside: avoid; break-inside: avoid; }
.section-title { page-break-after: avoid; break-after: avoid; }
.attr-tbl tr { page-break-inside: avoid; break-inside: avoid; }
.attr-tbl thead { display: table-header-group; }
.rpt-footer { page-break-inside: avoid; break-inside: avoid; margin-top: 20px; }
svg { max-width: 100%; height: auto; }
/* Darken light grays for PDF readability */
.rpt-header-left p, .rpt-header-right { color: #374151; }
.rpt-header-right .rid { color: #4b5563; }
.exec-text p { color: #374151; }
.card-label { color: #4b5563; }
.rpt-footer { color: #4b5563; }
[style*="color:#64748b"] { color: #374151 !important; }
[style*="color:#94a3b8"] { color: #4b5563 !important; }
[style*="color: #64748b"] { color: #374151 !important; }
[style*="color: #94a3b8"] { color: #4b5563 !important; }
[style*="color:#16a34a"], [style*="color: #16a34a"] { color: #16a34a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
[style*="color:#dc2626"] { color: #dc2626 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
[style*="color:#ca8a04"] { color: #ca8a04 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.health-ring, .card-value, .f-tag { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
/* Top bar for screen only */
.top-bar {
position: fixed; top: 0; left: 0; right: 0; background: #0f172a; color: #e2e8f0;
padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; z-index: 100;
font-size: 13px;
}
.top-bar-left { display: flex; align-items: center; gap: 12px; }
.top-bar-title { font-weight: 600; }
.top-bar-subtitle { font-size: 11px; color: #94a3b8; }
.top-bar button {
background: #06b6d4; color: #fff; border: none; padding: 10px 20px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer;
}
.top-bar button:hover { background: #0891b2; }
/* Header */
.rpt-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 0; border-bottom: 3px solid #0f172a; margin-bottom: 22px;
}
.rpt-header-left { display: flex; align-items: center; gap: 14px; }
.rpt-header-left img { height: 44px; width: auto; }
.rpt-header-left h1 { font-size: 22px; font-weight: 700; color: #0f172a; }
.rpt-header-left p { font-size: 11px; color: #64748b; }
.rpt-header-right { text-align: right; font-size: 11px; color: #64748b; line-height: 1.6; }
.rpt-header-right .rid { font-family: monospace; font-size: 10px; color: #94a3b8; }
/* Sections */
.section { margin-bottom: 22px; }
.section-title {
font-size: 14px; font-weight: 700; color: #0f172a; text-transform: uppercase;
letter-spacing: 0.05em; padding-bottom: 5px; border-bottom: 2px solid #e2e8f0; margin-bottom: 12px;
}
/* Executive summary */
.exec-box {
display: flex; align-items: flex-start; gap: 20px; padding: 20px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 16px;
}
.health-ring {
width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column;
align-items: center; justify-content: center; border: 4px solid; flex-shrink: 0;
}
.health-icon { font-size: 32px; line-height: 1; }
.health-lbl { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; margin-top: 4px; }
.exec-text { flex: 1; min-width: 200px; }
.exec-text h3 { font-size: 16px; margin-bottom: 4px; }
.exec-text p { font-size: 12px; color: #64748b; line-height: 1.5; }
/* Grids */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
.card-c { text-align: center; }
.card-c .card-value { font-size: 20px; font-weight: 800; }
/* Tags */
.f-tag { font-size: 9px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
/* Tables */
.attr-tbl { width: 100%; border-collapse: collapse; font-size: 11px; }
.attr-tbl th { text-align: left; padding: 6px 4px; font-size: 10px; color: #64748b; font-weight: 600; border-bottom: 2px solid #e2e8f0; background: #f1f5f9; }
.attr-tbl td { padding: 5px 4px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
.attr-tbl tr:hover { background: #f8fafc; }
.attr-tbl .col-name { word-break: break-word; }
.attr-tbl .col-raw { font-family: monospace; font-size: 10px; }
/* Attribute explanation rows: full-width below the data row */
.attr-explain-row td { padding-top: 0 !important; }
.attr-explain-row:hover { background: transparent; }
/* Recommendations */
.rec-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px; border-radius: 6px; margin-bottom: 8px; }
.rec-icon { font-size: 18px; flex-shrink: 0; width: 24px; text-align: center; }
.rec-item strong { display: block; margin-bottom: 2px; }
.rec-item p { font-size: 12px; color: #64748b; margin: 0; }
.rec-ok { background: #dcfce7; border: 1px solid #86efac; }
.rec-ok .rec-icon { color: #16a34a; }
.rec-warn { background: #fef3c7; border: 1px solid #fcd34d; }
.rec-warn .rec-icon { color: #ca8a04; }
.rec-critical { background: #fee2e2; border: 1px solid #fca5a5; }
.rec-critical .rec-icon { color: #dc2626; }
.rec-info { background: #e0f2fe; border: 1px solid #7dd3fc; }
.rec-info .rec-icon { color: #0284c7; }
/* Footer */
.rpt-footer {
margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0;
display: flex; justify-content: space-between; font-size: 10px; color: #94a3b8;
}
/* NOTE: No mobile-specific layout overrides — print layout is always A4/desktop
regardless of the device generating the PDF. The @media print block above
handles all necessary print adjustments. */
</style>
</head>
<body>
<script>
function pmxPrint(){
try { window.print(); }
catch(e) {
var isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
var el = document.getElementById('pmx-print-hint');
if(el) el.textContent = isMac ? 'Use Cmd+P to save as PDF' : 'Use Ctrl+P to save as PDF';
}
}
</script>
<!-- Top bar (screen only) -->
<div class="top-bar no-print">
<div style="display:flex;align-items:center;gap:12px;">
<strong>SMART Health Report</strong>
<span id="pmx-print-hint" style="font-size:11px;opacity:0.7;">/dev/${disk.name}</span>
</div>
<button onclick="pmxPrint()">Print / Save as PDF</button>
</div>
<!-- Header -->
<div class="rpt-header">
<div class="rpt-header-left">
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'">
<div>
<h1>SMART Health Report</h1>
<p>ProxMenux Monitor - Disk Health Analysis</p>
</div>
</div>
<div class="rpt-header-right">
<div>Date: ${now}</div>
<div>Device: /dev/${disk.name}</div>
<div class="rid">ID: ${reportId}</div>
</div>
</div>
<!-- 1. Executive Summary -->
<div class="section">
<div class="section-title">1. Executive Summary</div>
<div class="exec-box">
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<div class="health-ring" style="border-color:${healthColor};color:${healthColor}">
<div class="health-icon">${isHealthy ? '&#10003;' : '&#10007;'}</div>
<div class="health-lbl">${healthLabel}</div>
</div>
<div style="font-size:10px;color:#475569;font-weight:600;">SMART Status</div>
</div>
<div class="exec-text">
<h3>Disk Health Assessment</h3>
<p>
${isHealthy
? `This disk is operating within normal parameters. All SMART attributes are within acceptable thresholds. The disk has been powered on for approximately ${powerOnFormatted} and is currently operating at ${disk.temperature > 0 ? disk.temperature + '°C' : 'N/A'}. ${(disk.reallocated_sectors ?? 0) === 0 ? 'No bad sectors have been detected.' : `${disk.reallocated_sectors} reallocated sector(s) detected - monitor closely.`}`
: `This disk has reported a SMART health failure. Immediate action is required. Backup all critical data and plan for disk replacement.`
}
</p>
</div>
</div>
<!-- Simple Explanation for Non-Technical Users -->
<div style="background:${isHealthy ? '#dcfce7' : (hasCritical ? '#fee2e2' : '#fef3c7')};border:1px solid ${isHealthy ? '#86efac' : (hasCritical ? '#fca5a5' : '#fcd34d')};border-radius:8px;padding:16px;margin-top:12px;">
<div style="font-weight:700;font-size:14px;color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};margin-bottom:8px;">
${isHealthy ? 'What does this mean? Your disk is healthy!' : (hasCritical ? 'ATTENTION REQUIRED: Problems detected' : 'Some issues need monitoring')}
</div>
<p style="color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};font-size:12px;margin:0 0 8px 0;">
${isHealthy
? 'In simple terms: This disk is working properly. You can continue using it normally. We recommend running periodic SMART tests (monthly) to catch any issues early.'
: (hasCritical
? 'In simple terms: This disk has problems that could cause data loss. You should back up your important files immediately and consider replacing the disk soon.'
: 'In simple terms: The disk is working but shows some signs of wear. It is not critical yet, but you should monitor it closely and ensure your backups are up to date.'
)
}
</p>
${!isHealthy && criticalAttrs.length > 0 ? `
<div style="margin-top:8px;padding-top:8px;border-top:1px solid ${hasCritical ? '#fca5a5' : '#fcd34d'};">
<div style="font-size:11px;font-weight:600;color:#475569;margin-bottom:4px;">Issues found:</div>
<ul style="margin:0;padding-left:20px;font-size:11px;color:${hasCritical ? '#991b1b' : '#92400e'};">
${criticalAttrs.slice(0, 3).map(a => `<li>${a.name.replace(/_/g, ' ')}: ${a.status === 'critical' ? 'Critical - requires immediate attention' : 'Warning - should be monitored'}</li>`).join('')}
${criticalAttrs.length > 3 ? `<li>...and ${criticalAttrs.length - 3} more issues (see details below)</li>` : ''}
</ul>
</div>
` : ''}
</div>
<!-- Test Information -->
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr));gap:8px;margin-top:12px;">
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:10px 12px;">
<div style="font-size:10px;color:#475569;font-weight:600;text-transform:uppercase;">Report Generated</div>
<div style="font-size:12px;font-weight:600;color:#1e293b;">${now}</div>
</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:10px 12px;">
<div style="font-size:10px;color:#475569;font-weight:600;text-transform:uppercase;">${isHistorical ? 'Test Type' : 'Last Test Type'}</div>
<div style="font-size:12px;font-weight:600;color:#1e293b;">${testStatus.last_test?.type || 'N/A'}</div>
</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:10px 12px;">
<div style="font-size:10px;color:#475569;font-weight:600;text-transform:uppercase;">Test Result</div>
<div style="font-size:12px;font-weight:600;color:${testStatus.last_test?.status?.toLowerCase() === 'passed' ? '#16a34a' : testStatus.last_test?.status?.toLowerCase() === 'failed' ? '#dc2626' : '#64748b'};">${testStatus.last_test?.status || 'N/A'}</div>
</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:10px 12px;">
<div style="font-size:10px;color:#475569;font-weight:600;text-transform:uppercase;">Attributes Checked</div>
<div style="font-size:12px;font-weight:600;color:#1e293b;">${smartAttributes.length}</div>
</div>
</div>
${testAgeWarning ? `
<div style="background:#fef3c7;border:1px solid #fcd34d;border-radius:8px;padding:12px 16px;margin-top:12px;display:flex;align-items:flex-start;gap:10px;">
<span style="font-size:18px;flex-shrink:0;">&#9888;</span>
<div>
<div style="font-weight:700;font-size:12px;color:#92400e;margin-bottom:4px;">Outdated Test Data (${testAgeDays} days old)</div>
<p style="font-size:11px;color:#92400e;margin:0;">${testAgeWarning}</p>
</div>
</div>
` : ''}
</div>
<!-- 2. Disk Information -->
<div class="section">
<div class="section-title">2. Disk Information</div>
<div class="grid-4">
<div class="card">
<div class="card-label">Model</div>
<div class="card-value" style="font-size:11px;">${disk.model || sd?.model || 'Unknown'}</div>
</div>
<div class="card">
<div class="card-label">Serial</div>
<div class="card-value" style="font-size:11px;font-family:monospace;">${disk.serial || sd?.serial || 'Unknown'}</div>
</div>
<div class="card">
<div class="card-label">Capacity</div>
<div class="card-value" style="font-size:11px;">${disk.size_formatted || 'Unknown'}</div>
</div>
<div class="card">
<div class="card-label">Type</div>
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (disk.rotation_rate ? `SAS ${disk.rotation_rate} RPM` : 'SAS SSD') : diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}</div>
</div>
</div>
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? `
<div class="grid-4" style="margin-top:8px;">
${modelFamily ? `<div class="card"><div class="card-label">Family</div><div class="card-value" style="font-size:11px;">${modelFamily}</div></div>` : ''}
${formFactor ? `<div class="card"><div class="card-label">Form Factor</div><div class="card-value" style="font-size:11px;">${formFactor}</div></div>` : ''}
${sataVersion ? `<div class="card"><div class="card-label">Interface</div><div class="card-value" style="font-size:11px;">${sataVersion}${ifaceSpeed ? ` · ${ifaceSpeed}` : ''}</div></div>` : (ifaceSpeed ? `<div class="card"><div class="card-label">${isSasDisk ? 'Transport' : 'Link Speed'}</div><div class="card-value" style="font-size:11px;">${ifaceSpeed}</div></div>` : '')}
${!isNvmeDisk && !isSasDisk ? `<div class="card"><div class="card-label">TRIM</div><div class="card-value" style="font-size:11px;color:${trimSupported ? '#16a34a' : '#94a3b8'};">${trimSupported ? 'Supported' : 'Not supported'}${physBlockSize === 4096 ? ' · 4K AF' : ''}</div></div>` : ''}
${isSasDisk && sd?.logical_block_size ? `<div class="card"><div class="card-label">Block Size</div><div class="card-value" style="font-size:11px;">${sd.logical_block_size} bytes</div></div>` : ''}
</div>
` : ''}
<div class="grid-4">
<div class="card card-c">
<div class="card-value" style="color:${getTempColorForReport(disk.temperature)}">${disk.temperature > 0 ? disk.temperature + '°C' : 'N/A'}</div>
<div class="card-label">Temperature</div>
<div style="font-size:9px;color:#475569;margin-top:2px;">Optimal: ${tempThresholds.optimal}</div>
</div>
<div class="card card-c">
<div class="card-value">${fmtNum(powerOnHours)}h</div>
<div class="card-label">Power On Time</div>
<div style="font-size:9px;color:#475569;margin-top:2px;">${powerOnYears}y ${powerOnRemainingDays}d</div>
</div>
<div class="card card-c">
<div class="card-value">${fmtNum(disk.power_cycles ?? 0)}</div>
<div class="card-label">Power Cycles</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:${disk.smart_status?.toLowerCase() === 'passed' ? '#16a34a' : (disk.smart_status?.toLowerCase() === 'failed' ? '#dc2626' : '#64748b')}">${disk.smart_status || 'N/A'}</div>
<div class="card-label">SMART Status</div>
</div>
</div>
${!isNvmeDisk ? `
<div class="grid-3" style="margin-top:8px;">
<div class="card card-c">
<div class="card-value" style="color:${(disk.pending_sectors ?? 0) > 0 ? '#dc2626' : '#16a34a'}">${disk.pending_sectors ?? 0}</div>
<div class="card-label">${isSasDisk ? 'Uncorrected Errors' : 'Pending Sectors'}</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:${isSasDisk ? '#94a3b8' : (disk.crc_errors ?? 0) > 0 ? '#ca8a04' : '#16a34a'}">${isSasDisk ? 'N/A' : (disk.crc_errors ?? 0)}</div>
<div class="card-label">CRC Errors</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:${(disk.reallocated_sectors ?? 0) > 0 ? '#dc2626' : '#16a34a'}">${disk.reallocated_sectors ?? 0}</div>
<div class="card-label">${isSasDisk ? 'Grown Defects' : 'Reallocated Sectors'}</div>
</div>
</div>
` : ''}
${temperatureChartHtml}
</div>
${isNvmeDisk && hasNvmeWearData ? `
<!-- NVMe Wear & Lifetime (Special Section) -->
<div class="section">
<div class="section-title">3. NVMe Wear & Lifetime</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;">
<!-- Life Remaining Gauge -->
<div style="background:linear-gradient(135deg,#f8fafc 0%,#f1f5f9 100%);border:1px solid #e2e8f0;border-radius:12px;padding:20px;text-align:center;">
<div style="font-size:12px;color:#475569;margin-bottom:8px;font-weight:600;">LIFE REMAINING</div>
<div style="position:relative;width:120px;height:120px;margin:0 auto;">
<svg viewBox="0 0 120 120" style="transform:rotate(-90deg);">
<circle cx="60" cy="60" r="50" fill="none" stroke="#e2e8f0" stroke-width="12"/>
<circle cx="60" cy="60" r="50" fill="none" stroke="${getLifeColorHex(nvmePercentUsed)}" stroke-width="12"
stroke-dasharray="${(100 - nvmePercentUsed) * 3.14} 314" stroke-linecap="round"/>
</svg>
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;">
<div style="font-size:28px;font-weight:700;color:${getLifeColorHex(nvmePercentUsed)};">${100 - nvmePercentUsed}%</div>
</div>
</div>
<div style="margin-top:12px;font-size:13px;color:#475569;">Estimated: <strong>${nvmeEstimatedLife}</strong></div>
</div>
<!-- Usage Statistics -->
<div style="background:linear-gradient(135deg,#f8fafc 0%,#f1f5f9 100%);border:1px solid #e2e8f0;border-radius:12px;padding:20px;">
<div style="font-size:12px;color:#475569;margin-bottom:12px;font-weight:600;">USAGE STATISTICS</div>
<div style="margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:12px;color:#475569;">Percentage Used</span>
<span style="font-size:14px;font-weight:600;color:#3b82f6;">${nvmePercentUsed}%</span>
</div>
<div style="background:#e2e8f0;border-radius:4px;height:8px;overflow:hidden;">
<div style="background:#3b82f6;height:100%;width:${Math.min(nvmePercentUsed, 100)}%;border-radius:4px;"></div>
</div>
</div>
<div style="margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:12px;color:#475569;">Available Spare</span>
<span style="font-size:14px;font-weight:600;color:${nvmeAvailSpare >= 50 ? '#16a34a' : nvmeAvailSpare >= 20 ? '#ca8a04' : '#dc2626'};">${nvmeAvailSpare}%</span>
</div>
<div style="background:#e2e8f0;border-radius:4px;height:8px;overflow:hidden;">
<div style="background:${nvmeAvailSpare >= 50 ? '#16a34a' : nvmeAvailSpare >= 20 ? '#ca8a04' : '#dc2626'};height:100%;width:${nvmeAvailSpare}%;border-radius:4px;"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #e2e8f0;">
<div>
<div style="font-size:11px;color:#475569;">Data Written</div>
<div style="font-size:15px;font-weight:600;color:#1e293b;">${nvmeDataWrittenTB >= 1 ? nvmeDataWrittenTB.toFixed(2) + ' TB' : (nvmeDataWrittenTB * 1024).toFixed(1) + ' GB'}</div>
</div>
<div>
<div style="font-size:11px;color:#475569;">Power Cycles</div>
<div style="font-size:15px;font-weight:600;color:#1e293b;">${testStatus.smart_data?.nvme_raw?.power_cycles != null ? fmtNum(testStatus.smart_data.nvme_raw.power_cycles) : (disk.power_cycles ? fmtNum(disk.power_cycles) : 'N/A')}</div>
</div>
</div>
</div>
</div>
<!-- NVMe Extended Health Metrics -->
${(() => {
const nr = testStatus.smart_data?.nvme_raw
if (!nr) return ''
const mediaErr = nr.media_errors ?? 0
const unsafeSd = nr.unsafe_shutdowns ?? 0
const critWarn = nr.critical_warning ?? 0
const warnTempMin = nr.warning_temp_time ?? 0
const critTempMin = nr.critical_comp_time ?? 0
const ctrlBusy = nr.controller_busy_time ?? 0
const errLog = nr.num_err_log_entries ?? 0
const dataReadTB = ((nr.data_units_read ?? 0) * 512 * 1024) / (1024 ** 4)
const hostReads = nr.host_read_commands ?? 0
const hostWrites = nr.host_write_commands ?? 0
const endGrpWarn = nr.endurance_grp_critical_warning_summary ?? 0
const sensors = (nr.temperature_sensors ?? []).filter((s: number | null) => s !== null) as number[]
const metricCard = (label: string, value: string, colorHex: string, note?: string) =>
`<div class="card"><div class="card-label">${label}</div><div class="card-value" style="font-size:12px;color:${colorHex};">${value}</div>${note ? `<div style="font-size:9px;color:#64748b;margin-top:2px;">${note}</div>` : ''}</div>`
return `
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #e2e8f0;">
<div style="font-size:11px;font-weight:600;color:#475569;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:10px;">Extended NVMe Health</div>
<div class="grid-4">
${metricCard('Critical Warning', critWarn === 0 ? 'None' : `0x${critWarn.toString(16).toUpperCase()}`, critWarn === 0 ? '#16a34a' : '#dc2626', 'Controller alert flags')}
${metricCard('Media Errors', fmtNum(mediaErr), mediaErr === 0 ? '#16a34a' : '#dc2626', 'Flash cell damage')}
${metricCard('Unsafe Shutdowns', fmtNum(unsafeSd), unsafeSd < 50 ? '#16a34a' : unsafeSd < 200 ? '#ca8a04' : '#dc2626', 'Power loss without flush')}
${metricCard('Endurance Warning', endGrpWarn === 0 ? 'None' : `0x${endGrpWarn.toString(16).toUpperCase()}`, endGrpWarn === 0 ? '#16a34a' : '#ca8a04', 'Group endurance alert')}
</div>
<div class="grid-4" style="margin-top:8px;">
${metricCard('Controller Busy', `${fmtNum(ctrlBusy)} min`, '#1e293b', 'Total busy time')}
${metricCard('Error Log Entries', fmtNum(errLog), errLog === 0 ? '#16a34a' : '#ca8a04', 'May include benign artifacts')}
${metricCard('Warning Temp Time', `${fmtNum(warnTempMin)} min`, warnTempMin === 0 ? '#16a34a' : '#ca8a04', 'Minutes in warning range')}
${metricCard('Critical Temp Time', `${fmtNum(critTempMin)} min`, critTempMin === 0 ? '#16a34a' : '#dc2626', 'Minutes in critical range')}
</div>
<div class="grid-4" style="margin-top:8px;">
${metricCard('Data Read', dataReadTB >= 1 ? dataReadTB.toFixed(2) + ' TB' : (dataReadTB * 1024).toFixed(1) + ' GB', '#1e293b', 'Total host reads')}
${metricCard('Host Read Cmds', fmtNum(hostReads), '#1e293b', 'Total read commands')}
${metricCard('Host Write Cmds', fmtNum(hostWrites), '#1e293b', 'Total write commands')}
${sensors.length >= 2 ? metricCard('Hotspot Temp', `${sensors[1]}°C`, sensors[1] > 80 ? '#dc2626' : sensors[1] > 70 ? '#ca8a04' : '#16a34a', 'Sensor[1] hotspot') : '<div class="card"><div class="card-label">Sensors</div><div class="card-value" style="font-size:11px;color:#94a3b8;">N/A</div></div>'}
</div>
</div>`
})()}
</div>
` : ''}
${isNvmeDisk && !hasNvmeWearData ? (() => {
// Fallback for NVMe drives whose firmware does not tick percent_used
// (e.g. WD CL SN720). Skip the misleading "100% Life Remaining /
// Excellent" gauge and only print the data we trust: total written.
const dwUnits = testStatus.smart_data?.nvme_raw?.data_units_written ?? 0
if (!dwUnits) return ''
const dwTB = (dwUnits * 512 * 1024) / (1024 ** 4)
const dwLabel = dwTB >= 1 ? dwTB.toFixed(2) + ' TB' : (dwTB * 1024).toFixed(1) + ' GB'
const pCycles = testStatus.smart_data?.nvme_raw?.power_cycles ?? disk.power_cycles ?? null
return `
<!-- NVMe wear-not-reported fallback -->
<div class="section">
<div class="section-title">3. NVMe Wear &amp; Lifetime</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px 16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div>
<div style="font-size:10px;color:#475569;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Data Written</div>
<div style="font-size:18px;font-weight:700;color:#1e293b;margin-top:4px;">${dwLabel}</div>
</div>
${pCycles !== null ? `<div>
<div style="font-size:10px;color:#475569;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Power Cycles</div>
<div style="font-size:18px;font-weight:700;color:#1e293b;margin-top:4px;">${fmtNum(pCycles as number)}</div>
</div>` : ''}
</div>
</div>
</div>
`
})() : ''}
${!isNvmeDisk && diskType === 'SSD' ? (() => {
// Try to find SSD wear indicators from SMART attributes
const wearAttr = smartAttributes.find(a =>
a.name?.toLowerCase().includes('wear_leveling') ||
a.name?.toLowerCase().includes('media_wearout') ||
a.name?.toLowerCase().includes('percent_lifetime') ||
a.name?.toLowerCase().includes('ssd_life_left') ||
a.id === 177 || a.id === 231 || a.id === 233
)
const lbasWrittenAttr = smartAttributes.find(a =>
a.name?.toLowerCase().includes('total_lbas_written') ||
a.id === 241
)
// Also check disk properties — cast to number since SmartAttribute.value is number | string
const wearRaw = (wearAttr?.value !== undefined ? Number(wearAttr.value) : undefined) ?? disk.wear_leveling_count ?? disk.ssd_life_left
if (wearRaw !== undefined && wearRaw !== null) {
// ID 230 (Media_Wearout_Indicator on WD/SanDisk): value = endurance used %
// All others (ID 177, 231, etc.): value = life remaining %
const lifeRemaining = (wearAttr?.id === 230) ? (100 - wearRaw) : wearRaw
const lifeUsed = 100 - lifeRemaining
// Calculate data written — detect unit from attribute name
let dataWrittenTB = 0
if (lbasWrittenAttr?.raw_value) {
const rawValue = parseInt(lbasWrittenAttr.raw_value.replace(/[^0-9]/g, ''))
if (!isNaN(rawValue)) {
const attrName = (lbasWrittenAttr.name || '').toLowerCase()
if (attrName.includes('gib') || attrName.includes('_gb')) {
// Raw value already in GiB (WD Blue, Kingston, etc.)
dataWrittenTB = rawValue / 1024
} else {
// Raw value in LBAs — multiply by 512 bytes (Seagate, standard)
dataWrittenTB = (rawValue * 512) / (1024 ** 4)
}
}
} else if (disk.total_lbas_written) {
dataWrittenTB = disk.total_lbas_written / 1024 // Already in GB from backend
}
return `
<!-- SSD Wear & Lifetime -->
<div class="section">
<div class="section-title">3. SSD Wear & Lifetime</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;">
<!-- Life Remaining Gauge -->
<div style="background:linear-gradient(135deg,#f8fafc 0%,#f1f5f9 100%);border:1px solid #e2e8f0;border-radius:12px;padding:20px;text-align:center;">
<div style="font-size:12px;color:#475569;margin-bottom:8px;font-weight:600;">LIFE REMAINING</div>
<div style="position:relative;width:120px;height:120px;margin:0 auto;">
<svg viewBox="0 0 120 120" style="transform:rotate(-90deg);">
<circle cx="60" cy="60" r="50" fill="none" stroke="#e2e8f0" stroke-width="12"/>
<circle cx="60" cy="60" r="50" fill="none" stroke="${getLifeColorHex(lifeUsed)}" stroke-width="12"
stroke-dasharray="${lifeRemaining * 3.14} 314" stroke-linecap="round"/>
</svg>
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;">
<div style="font-size:28px;font-weight:700;color:${getLifeColorHex(lifeUsed)};">${lifeRemaining}%</div>
</div>
</div>
<div style="margin-top:12px;font-size:11px;color:#475569;">
Source: ${wearAttr?.name?.replace(/_/g, ' ') || 'SSD Life Indicator'}
</div>
</div>
<!-- Usage Statistics -->
<div style="background:linear-gradient(135deg,#f8fafc 0%,#f1f5f9 100%);border:1px solid #e2e8f0;border-radius:12px;padding:20px;">
<div style="font-size:12px;color:#475569;margin-bottom:12px;font-weight:600;">USAGE STATISTICS</div>
<div style="margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:12px;color:#475569;">Wear Level</span>
<span style="font-size:14px;font-weight:600;color:#3b82f6;">${lifeUsed}%</span>
</div>
<div style="background:#e2e8f0;border-radius:4px;height:8px;overflow:hidden;">
<div style="background:#3b82f6;height:100%;width:${Math.min(lifeUsed, 100)}%;border-radius:4px;"></div>
</div>
</div>
${dataWrittenTB > 0 ? `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #e2e8f0;">
<div>
<div style="font-size:11px;color:#475569;">Data Written</div>
<div style="font-size:15px;font-weight:600;color:#1e293b;">${dataWrittenTB >= 1 ? dataWrittenTB.toFixed(2) + ' TB' : (dataWrittenTB * 1024).toFixed(1) + ' GB'}</div>
</div>
<div>
<div style="font-size:11px;color:#475569;">Power On Hours</div>
<div style="font-size:15px;font-weight:600;color:#1e293b;">${fmtNum(powerOnHours)}h</div>
</div>
</div>
` : ''}
<div style="margin-top:12px;padding:8px;background:#f1f5f9;border-radius:6px;font-size:11px;color:#475569;">
<strong>Note:</strong> SSD life estimates are based on manufacturer-reported wear indicators.
Actual lifespan may vary based on workload and usage patterns.
</div>
</div>
</div>
</div>
`
}
return ''
})() : ''}
<!-- SMART Attributes / NVMe Health Metrics / SAS Error Counters -->
<div class="section">
<div class="section-title">${isNvmeDisk ? '4' : (diskType === 'SSD' && (disk.wear_leveling_count !== undefined || disk.ssd_life_left !== undefined || smartAttributes.some(a => a.name?.toLowerCase().includes('wear'))) ? '4' : '3')}. ${isNvmeDisk ? 'NVMe Health Metrics' : isSasDisk ? 'SAS/SCSI Health Metrics' : 'SMART Attributes'} (${smartAttributes.length} total${hasCritical ? `, ${criticalAttrs.length} warning(s)` : ''})</div>
<table class="attr-tbl">
<thead>
<tr>
${useSimpleTable ? '' : '<th style="width:28px;">ID</th>'}
<th class="col-name">${isNvmeDisk ? 'Metric' : isSasDisk ? 'Metric' : 'Attribute'}</th>
<th style="text-align:center;width:${useSimpleTable ? '80px' : '40px'};">Value</th>
${useSimpleTable ? '' : '<th style="text-align:center;width:40px;">Worst</th>'}
${useSimpleTable ? '' : '<th style="text-align:center;width:40px;">Thr</th>'}
${useSimpleTable ? '' : '<th class="col-raw" style="width:60px;">Raw</th>'}
<th style="width:36px;"></th>
</tr>
</thead>
<tbody>
${attributeRows || '<tr><td colspan="' + (useSimpleTable ? '3' : '7') + '" style="text-align:center;color:#64748b;padding:20px;">No ' + (isNvmeDisk ? 'NVMe metrics' : isSasDisk ? 'SAS metrics' : 'SMART attributes') + ' available</td></tr>'}
</tbody>
</table>
</div>
<!-- 5. Last Test Result -->
<div class="section">
<div class="section-title">${isNvmeDisk ? '5' : '4'}. ${isHistorical ? 'Self-Test Result' : 'Last Self-Test Result'}</div>
${testStatus.last_test ? `
<div class="grid-4">
<div class="card">
<div class="card-label">Test Type</div>
<div class="card-value" style="text-transform:capitalize;">${testStatus.last_test.type}</div>
</div>
<div class="card">
<div class="card-label">Result</div>
<div class="card-value" style="color:${testStatus.last_test.status === 'passed' ? '#16a34a' : '#dc2626'};text-transform:capitalize;">${testStatus.last_test.status}</div>
</div>
<div class="card">
<div class="card-label">Completed</div>
<div class="card-value" style="font-size:11px;">${testStatus.last_test.timestamp || 'N/A'}</div>
</div>
<div class="card">
<div class="card-label">At Power-On Hours</div>
<div class="card-value">${testStatus.last_test.lifetime_hours ? fmtNum(testStatus.last_test.lifetime_hours) + 'h' : 'N/A'}</div>
</div>
</div>
${(pollingShort || pollingExt) ? `
<div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;">
${pollingShort ? `<div style="background:#f1f5f9;border:1px solid #e2e8f0;border-radius:6px;padding:6px 12px;font-size:11px;color:#475569;"><strong>Short test:</strong> ~${pollingShort} min</div>` : ''}
${pollingExt ? `<div style="background:#f1f5f9;border:1px solid #e2e8f0;border-radius:6px;padding:6px 12px;font-size:11px;color:#475569;"><strong>Extended test:</strong> ~${pollingExt} min</div>` : ''}
${errorLogCount > 0 ? `<div style="background:#fef3c7;border:1px solid #fcd34d;border-radius:6px;padding:6px 12px;font-size:11px;color:#92400e;"><strong>ATA error log:</strong> ${errorLogCount} entr${errorLogCount === 1 ? 'y' : 'ies'}</div>` : ''}
</div>` : ''}
${selfTestHistory.length > 1 ? `
<div style="margin-top:14px;">
<div style="font-size:11px;font-weight:600;color:#475569;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;">Full Self-Test History (${selfTestHistory.length} entries)</div>
<table class="attr-tbl">
<thead>
<tr>
<th>#</th>
<th>Type</th>
<th>Status</th>
<th>At POH</th>
</tr>
</thead>
<tbody>
${selfTestHistory.map((e, i) => `
<tr>
<td style="color:#94a3b8;">${i + 1}</td>
<td style="text-transform:capitalize;">${e.type_str || e.type}</td>
<td><span class="f-tag" style="background:${e.status === 'passed' ? '#16a34a15' : '#dc262615'};color:${e.status === 'passed' ? '#16a34a' : '#dc2626'};">${e.status_str || e.status}</span></td>
<td style="font-family:monospace;">${e.lifetime_hours != null ? fmtNum(e.lifetime_hours) + 'h' : 'N/A'}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : ''}
` : lastTestDate ? `
<div class="grid-4">
<div class="card">
<div class="card-label">${isHistorical ? 'Test Type' : 'Last Test Type'}</div>
<div class="card-value" style="text-transform:capitalize;">${testStatus.test_type || 'Extended'}</div>
</div>
<div class="card">
<div class="card-label">Result</div>
<div class="card-value" style="color:#16a34a;">Passed</div>
</div>
<div class="card">
<div class="card-label">Date</div>
<div class="card-value" style="font-size:11px;">${new Date(lastTestDate).toLocaleString()}</div>
</div>
<div class="card">
<div class="card-label">At Power-On Hours</div>
<div class="card-value">${fmtNum(powerOnHours)}h</div>
</div>
</div>
<div style="margin-top:8px;padding:8px 12px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:6px;font-size:11px;color:#475569;">
<strong>Note:</strong> This disk's firmware does not maintain an internal self-test log. Test results are tracked by ProxMenux Monitor.
</div>
` : `
<div style="text-align:center;padding:20px;color:#64748b;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;">
No self-test history available. Run a SMART self-test to see results here.
</div>
`}
</div>
${observationsHtml}
<!-- Recommendations -->
<div class="section">
<div class="section-title">${observations.length > 0 ? (isNvmeDisk ? '7' : '6') : (isNvmeDisk ? '6' : '5')}. Recommendations</div>
${recommendations.join('')}
</div>
<!-- Footer -->
<div class="rpt-footer">
<div>Report generated by ProxMenux Monitor</div>
<div>ProxMenux Monitor v1.2.2.2-beta</div>
</div>
</body>
</html>`
const blob = new Blob([html], { type: "text/html" })
const url = URL.createObjectURL(blob)
if (targetWindow && !targetWindow.closed) {
// Navigate the already-open window to the blob URL (proper navigation with back/close in webapp)
targetWindow.location.href = url
} else {
window.open(url, "_blank")
}
}
// SMART Test Tab Component
interface SmartTestTabProps {
disk: DiskInfo
observations?: DiskObservation[]
lastTestDate?: string
}
interface SmartSelfTestEntry {
type: 'short' | 'long' | 'other'
type_str: string
status: 'passed' | 'failed'
status_str: string
lifetime_hours: number | null
}
interface SmartAttribute {
id: number
name: string
value: number | string
worst: number | string
threshold: number | string
raw_value: string
status: 'ok' | 'warning' | 'critical'
prefailure?: boolean
flags?: string
}
interface NvmeRaw {
critical_warning: number
temperature: number
avail_spare: number
spare_thresh: number
percent_used: number
endurance_grp_critical_warning_summary: number
data_units_read: number
data_units_written: number
host_read_commands: number
host_write_commands: number
controller_busy_time: number
power_cycles: number
power_on_hours: number
unsafe_shutdowns: number
media_errors: number
num_err_log_entries: number
warning_temp_time: number
critical_comp_time: number
temperature_sensors: (number | null)[]
}
interface SmartTestStatus {
status: 'idle' | 'running' | 'completed' | 'failed'
test_type?: string
progress?: number
result?: string
supports_progress_reporting?: boolean
supports_self_test?: boolean
last_test?: {
type: string
status: string
timestamp: string
duration?: string
lifetime_hours?: number
}
smart_data?: {
device: string
model: string
model_family?: string
serial: string
firmware: string
nvme_version?: string
smart_status: string
temperature: number
temperature_sensors?: (number | null)[]
power_on_hours: number
power_cycles?: number
rotation_rate?: number
form_factor?: string
physical_block_size?: number
trim_supported?: boolean
sata_version?: string
interface_speed?: string
polling_minutes_short?: number
polling_minutes_extended?: number
supports_progress_reporting?: boolean
error_log_count?: number
self_test_history?: SmartSelfTestEntry[]
attributes: SmartAttribute[]
nvme_raw?: NvmeRaw
is_sas?: boolean
logical_block_size?: number
}
tools_installed?: {
smartctl: boolean
nvme: boolean
}
}
function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabProps) {
const [testStatus, setTestStatus] = useState<SmartTestStatus>({ status: 'idle' })
const [loading, setLoading] = useState(true)
const [runningTest, setRunningTest] = useState<'short' | 'long' | null>(null)
// Extract SMART attributes from testStatus for the report
const smartAttributes = testStatus.smart_data?.attributes || []
const fetchSmartStatus = async () => {
try {
setLoading(true)
const data = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
setTestStatus(data)
return data
} catch {
setTestStatus({ status: 'idle' })
return { status: 'idle' }
} finally {
setLoading(false)
}
}
// Fetch current SMART status on mount and start polling if test is running
useEffect(() => {
let pollInterval: NodeJS.Timeout | null = null
const checkAndPoll = async () => {
const data = await fetchSmartStatus()
// If a test is already running, start polling
if (data.status === 'running') {
pollInterval = setInterval(async () => {
try {
const status = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
setTestStatus(status)
if (status.status !== 'running' && pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
} catch {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
}, 5000)
}
}
checkAndPoll()
return () => {
if (pollInterval) clearInterval(pollInterval)
}
}, [disk.name])
const [testError, setTestError] = useState<string | null>(null)
const [installing, setInstalling] = useState(false)
// Check if required tools are installed for this disk type
const isNvme = disk.name.includes('nvme')
const toolsAvailable = testStatus.tools_installed
? (isNvme ? testStatus.tools_installed.nvme : testStatus.tools_installed.smartctl)
: true // Assume true until we get the status
const installSmartTools = async () => {
try {
setInstalling(true)
setTestError(null)
const data = await fetchApi<{ success: boolean; error?: string }>('/api/storage/smart/tools/install', {
method: 'POST',
body: JSON.stringify({ install_all: true })
})
if (data.success) {
fetchSmartStatus()
} else {
setTestError(data.error || 'Installation failed. Try manually: apt-get install smartmontools nvme-cli')
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to install tools'
setTestError(`${message}. Try manually: apt-get install smartmontools nvme-cli`)
} finally {
setInstalling(false)
}
}
const runSmartTest = async (testType: 'short' | 'long') => {
try {
setRunningTest(testType)
setTestError(null)
await fetchApi(`/api/storage/smart/${disk.name}/test`, {
method: 'POST',
body: JSON.stringify({ test_type: testType })
})
// Immediately fetch status to show progress bar
fetchSmartStatus()
// Poll for status updates
// For disks that don't report progress, we keep polling but show an indeterminate progress bar
let pollCount = 0
const maxPolls = testType === 'short' ? 36 : 720 // 3 min for short, 1 hour for long (at 5s intervals)
const pollInterval = setInterval(async () => {
pollCount++
try {
const statusData = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
setTestStatus(statusData)
// Only clear runningTest when we get a definitive "not running" status
if (statusData.status !== 'running') {
clearInterval(pollInterval)
setRunningTest(null)
// Refresh SMART JSON data to get new test results
fetchSmartStatus()
}
} catch {
// Don't clear on error - keep showing progress
}
// Safety timeout: stop polling after max duration
if (pollCount >= maxPolls) {
clearInterval(pollInterval)
setRunningTest(null)
// Refresh status one more time to get final result
fetchSmartStatus()
}
}, 5000)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start test'
setTestError(message)
setRunningTest(null)
}
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading SMART data...</p>
</div>
)
}
// If tools not available, show install button only
if (!toolsAvailable && !loading) {
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="font-medium text-amber-500">SMART Tools Not Installed</p>
<p className="text-sm text-muted-foreground mt-1">
{isNvme
? 'nvme-cli is required to run SMART tests on NVMe disks.'
: 'smartmontools is required to run SMART tests on this disk.'}
</p>
</div>
</div>
<Button
onClick={installSmartTools}
disabled={installing}
className="w-full gap-2 bg-[#4A9BA8] hover:bg-[#3d8591] text-white border-0"
>
{installing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{installing ? 'Installing SMART Tools...' : 'Install SMART Tools'}
</Button>
{testError && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400">
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium">Installation Failed</p>
<p className="text-xs opacity-80">{testError}</p>
</div>
</div>
)}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Quick Actions */}
<div className="space-y-3">
<h4 className="font-semibold flex items-center gap-2">
<Play className="h-4 w-4" />
Run SMART Test
</h4>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
size="sm"
onClick={() => runSmartTest('short')}
disabled={runningTest !== null || testStatus.status === 'running'}
className="gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400"
>
{runningTest === 'short' || (testStatus.status === 'running' && testStatus.test_type === 'short') ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Activity className="h-4 w-4" />
)}
Short Test (~2 min)
</Button>
<Button
variant="outline"
size="sm"
onClick={() => runSmartTest('long')}
disabled={runningTest !== null || testStatus.status === 'running'}
className="gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400"
>
{runningTest === 'long' || (testStatus.status === 'running' && testStatus.test_type === 'long') ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Activity className="h-4 w-4" />
)}
Extended Test (background)
</Button>
</div>
<p className="text-xs text-muted-foreground">
Short test takes ~2 minutes. Extended test runs in the background and can take several hours for large disks.
You will receive a notification when the test completes.
</p>
{/* Error Message */}
{testError && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400">
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">Failed to start test</p>
<p className="text-xs opacity-80">{testError}</p>
</div>
</div>
)}
</div>
{/* Test Progress - Show when API reports running OR when we just started a test */}
{(testStatus.status === 'running' || runningTest !== null) && (
<div className="border rounded-lg p-4 bg-blue-500/5 border-blue-500/20">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
<div className="flex-1">
<p className="font-medium text-blue-500">
{(runningTest || testStatus.test_type) === 'short' ? 'Short' : 'Extended'} test in progress
</p>
<p className="text-xs text-muted-foreground">
Please wait while the test completes. Buttons will unlock when it finishes.
</p>
</div>
</div>
{/* Progress bar if disk reports percentage */}
{testStatus.progress !== undefined ? (
<Progress value={testStatus.progress} className="h-2 mt-3 [&>div]:bg-blue-500" />
) : (
<>
<div className="h-2 mt-3 rounded-full bg-blue-500/20 overflow-hidden">
<div className="h-full w-1/3 bg-blue-500 rounded-full animate-[indeterminate_1.5s_ease-in-out_infinite]"
style={{ animation: 'indeterminate 1.5s ease-in-out infinite' }} />
</div>
<p className="text-[11px] text-muted-foreground mt-2 flex items-center gap-1">
<Info className="h-3 w-3 flex-shrink-0" />
This disk&apos;s firmware does not support progress reporting. The test is running in the background.
</p>
</>
)}
</div>
)}
{/* Last Test Result — only show if a test was executed from ProxMenux (lastTestDate exists)
or if currently running/just completed a test. Tests from the drive's internal log
(e.g. factory tests) are only shown in the full SMART report. */}
{testStatus.last_test && lastTestDate && (
<div className="flex items-center gap-3 flex-wrap">
{testStatus.last_test.status === 'passed' ? (
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
) : (
<XCircle className="h-4 w-4 text-red-500 flex-shrink-0" />
)}
<span className="text-sm font-medium">
Last Test: {testStatus.last_test.type === 'short' ? 'Short' : 'Extended'}
</span>
<Badge className={testStatus.last_test.status === 'passed'
? 'bg-green-500/10 text-green-500 border-green-500/20'
: 'bg-red-500/10 text-red-500 border-red-500/20'
}>
{testStatus.last_test.status}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(lastTestDate).toLocaleString()}
</span>
</div>
)}
{/* SMART Attributes Summary */}
{testStatus.smart_data?.attributes && testStatus.smart_data.attributes.length > 0 && (
<div className="space-y-3">
<h4 className="font-semibold flex items-center gap-2">
<Activity className="h-4 w-4" />
{isNvme ? 'NVMe Health Metrics' : testStatus.smart_data?.is_sas ? 'SAS/SCSI Health Metrics' : 'SMART Attributes'}
</h4>
<div className="border rounded-lg overflow-hidden">
<div className={`grid ${(isNvme || testStatus.smart_data?.is_sas) ? 'grid-cols-10' : 'grid-cols-12'} gap-2 p-3 bg-muted/30 text-xs font-medium text-muted-foreground`}>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-1">ID</div>}
<div className={(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-5' : 'col-span-5'}>Attribute</div>
<div className={(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-3 text-center' : 'col-span-2 text-center'}>Value</div>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-2 text-center">Worst</div>}
<div className="col-span-2 text-center">Status</div>
</div>
<div className="divide-y divide-border max-h-[200px] overflow-y-auto">
{testStatus.smart_data.attributes.slice(0, 15).map((attr) => (
<div key={attr.id} className={`grid ${(isNvme || testStatus.smart_data?.is_sas) ? 'grid-cols-10' : 'grid-cols-12'} gap-2 p-3 text-sm items-center`}>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-1 text-muted-foreground">{attr.id}</div>}
<div className={`${(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-5' : 'col-span-5'} truncate`} title={attr.name}>{attr.name}</div>
<div className={`${(isNvme || testStatus.smart_data?.is_sas) ? 'col-span-3' : 'col-span-2'} text-center font-mono`}>{testStatus.smart_data?.is_sas ? attr.raw_value : attr.value}</div>
{!isNvme && !testStatus.smart_data?.is_sas && <div className="col-span-2 text-center font-mono text-muted-foreground">{attr.worst}</div>}
<div className="col-span-2 text-center">
{attr.status === 'ok' ? (
<CheckCircle2 className="h-4 w-4 text-green-500 mx-auto" />
) : attr.status === 'warning' ? (
<AlertTriangle className="h-4 w-4 text-yellow-500 mx-auto" />
) : (
<XCircle className="h-4 w-4 text-red-500 mx-auto" />
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* View Full Report Button */}
<div className="pt-4 border-t">
<Button
variant="outline"
className="w-full gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400"
onClick={async () => {
// Open placeholder window synchronously so the popup blocker
// sees the user gesture; then fetch temp history and hand
// the populated tempHistory + targetWindow to openSmartReport.
const reportWindow = window.open('about:blank', '_blank')
if (reportWindow) {
reportWindow.document.write('<html><body style="background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><div style="border:3px solid transparent;border-top-color:#06b6d4;border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:0 auto"></div><p style="margin-top:16px">Loading report...</p><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div></body></html>')
}
// Warm the disk-temp threshold cache in parallel with the
// history fetch so openSmartReport's sync read picks up
// the user's customised values instead of stale defaults.
const [tempHistory] = await Promise.all([
fetchTempHistoryForReport(disk.name),
loadDiskTempThresholds(),
])
openSmartReport(disk, testStatus, smartAttributes, observations, lastTestDate, reportWindow || undefined, false, tempHistory)
}}
>
<FileText className="h-4 w-4" />
View Full SMART Report
</Button>
<p className="text-xs text-muted-foreground text-center mt-2">
Generate a comprehensive professional report with detailed analysis and recommendations.
</p>
</div>
</div>
)
}
// ─── History Tab Component ──────────────────────────────────────────────────────
interface SmartHistoryEntry {
filename: string
path: string
timestamp: string
test_type: string
date_readable: string
}
function HistoryTab({ disk }: { disk: DiskInfo }) {
const [history, setHistory] = useState<SmartHistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [deleting, setDeleting] = useState<string | null>(null)
const [viewingReport, setViewingReport] = useState<string | null>(null)
const fetchHistory = async () => {
try {
setLoading(true)
const data = await fetchApi<{ history: SmartHistoryEntry[] }>(`/api/storage/smart/${disk.name}/history?limit=50`)
setHistory(data.history || [])
} catch {
setHistory([])
} finally {
setLoading(false)
}
}
useEffect(() => { fetchHistory() }, [disk.name])
const handleDelete = async (filename: string) => {
try {
setDeleting(filename)
await fetchApi(`/api/storage/smart/${disk.name}/history/${filename}`, { method: 'DELETE' })
setHistory(prev => prev.filter(h => h.filename !== filename))
} catch {
// Silently fail
} finally {
setDeleting(null)
}
}
const handleDownload = async (filename: string) => {
try {
const response = await fetchApi<Record<string, unknown>>(`/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) => {
// Open window IMMEDIATELY on user click (before async) to avoid popup blocker
const reportWindow = window.open('about:blank', '_blank')
if (reportWindow) {
reportWindow.document.write('<html><body style="background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><div style="border:3px solid transparent;border-top-color:#06b6d4;border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:0 auto"></div><p style="margin-top:16px">Loading report...</p><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div></body></html>')
}
try {
setViewingReport(entry.filename)
// Fetch full SMART status from backend (same data as SMART tab uses)
const fullStatus = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
const attrs = fullStatus.smart_data?.attributes || []
const [tempHistory] = await Promise.all([
fetchTempHistoryForReport(disk.name),
loadDiskTempThresholds(),
])
openSmartReport(disk, fullStatus, attrs, [], entry.timestamp, reportWindow || undefined, true, tempHistory)
} catch {
if (reportWindow && !reportWindow.closed) {
reportWindow.document.body.innerHTML = '<p style="color:#ef4444;text-align:center;margin-top:40vh">Failed to load report data.</p>'
}
} finally {
setViewingReport(null)
}
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading test history...</p>
</div>
)
}
if (history.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Archive className="h-12 w-12 mb-3 opacity-30" />
<span className="text-sm">No test history</span>
<span className="text-xs mt-1">Run a SMART test to start building history for this disk.</span>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-semibold flex items-center gap-2">
<Archive className="h-4 w-4" />
Test History
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">
{history.length}
</Badge>
</h4>
</div>
<div className="space-y-2">
{history.map((entry, i) => {
const isLatest = i === 0
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 (
<div
key={entry.filename}
onClick={() => !isDeleting && handleViewReport(entry)}
className={`border rounded-lg p-3 flex items-center gap-3 transition-colors cursor-pointer hover:bg-white/5 ${
isLatest ? 'border-orange-500/30' : 'border-border'
} ${isDeleting ? 'opacity-50 pointer-events-none' : ''} ${isViewing ? 'opacity-70' : ''}`}
>
{isViewing ? (
<Loader2 className="h-4 w-4 animate-spin text-orange-400 flex-shrink-0" />
) : (
<Badge className={`text-[10px] px-1.5 flex-shrink-0 ${
entry.test_type === 'long'
? 'bg-orange-500/10 text-orange-400 border-orange-500/20'
: 'bg-blue-500/10 text-blue-400 border-blue-500/20'
}`}>
{entry.test_type === 'long' ? 'Extended' : 'Short'}
</Badge>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{testDate.toLocaleString()}
{isLatest && <span className="text-[10px] text-orange-400 ml-2">latest</span>}
</p>
<p className="text-xs text-muted-foreground">
{ageDays === 0 ? 'Today' : ageDays === 1 ? 'Yesterday' : `${ageDays} days ago`}
</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost" size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-blue-400"
onClick={(e: unknown) => { (e as MouseEvent).stopPropagation(); handleDownload(entry.filename) }}
title="Download JSON"
>
<Download 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-red-400"
onClick={(e: unknown) => { (e as MouseEvent).stopPropagation(); if (confirm('Delete this test record?')) handleDelete(entry.filename) }}
disabled={isDeleting}
title="Delete"
>
{isDeleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
)
})}
</div>
<p className="text-xs text-muted-foreground text-center pt-2">
Test results are stored locally and used to generate detailed SMART reports.
</p>
</div>
)
}
// ─── Schedule Tab Component ─────────────────────────────────────────────────────
interface SmartSchedule {
id: string
active: boolean
test_type: 'short' | 'long'
frequency: 'daily' | 'weekly' | 'monthly'
hour: number
minute: number
day_of_week: number
day_of_month: number
disks: string[]
retention: number
notify_on_complete: boolean
notify_only_on_failure: boolean
}
interface ScheduleConfig {
enabled: boolean
schedules: SmartSchedule[]
}
function ScheduleTab({ disk }: { disk: DiskInfo }) {
const [config, setConfig] = useState<ScheduleConfig>({ enabled: true, schedules: [] })
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [showForm, setShowForm] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<SmartSchedule | null>(null)
// Form state
const [formData, setFormData] = useState<Partial<SmartSchedule>>({
test_type: 'short',
frequency: 'weekly',
hour: 3,
minute: 0,
day_of_week: 0,
day_of_month: 1,
disks: [disk.name],
retention: 10,
active: true,
notify_on_complete: true,
notify_only_on_failure: false
})
const fetchSchedules = async () => {
try {
setLoading(true)
const data = await fetchApi<ScheduleConfig>('/api/storage/smart/schedules')
setConfig(data)
} catch {
console.error('Failed to load schedules')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchSchedules()
}, [])
const handleToggleGlobal = async () => {
try {
setSaving(true)
await fetchApi('/api/storage/smart/schedules/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !config.enabled })
})
setConfig(prev => ({ ...prev, enabled: !prev.enabled }))
} catch {
console.error('Failed to toggle schedules')
} finally {
setSaving(false)
}
}
const handleSaveSchedule = async () => {
try {
setSaving(true)
const scheduleData = {
...formData,
id: editingSchedule?.id || undefined
}
await fetchApi('/api/storage/smart/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(scheduleData)
})
await fetchSchedules()
setShowForm(false)
setEditingSchedule(null)
resetForm()
} catch {
console.error('Failed to save schedule')
} finally {
setSaving(false)
}
}
const handleDeleteSchedule = async (id: string) => {
try {
setSaving(true)
await fetchApi(`/api/storage/smart/schedules/${id}`, {
method: 'DELETE'
})
await fetchSchedules()
} catch {
console.error('Failed to delete schedule')
} finally {
setSaving(false)
}
}
const resetForm = () => {
setFormData({
test_type: 'short',
frequency: 'weekly',
hour: 3,
minute: 0,
day_of_week: 0,
day_of_month: 1,
disks: [disk.name],
retention: 10,
active: true,
notify_on_complete: true,
notify_only_on_failure: false
})
}
const editSchedule = (schedule: SmartSchedule) => {
setEditingSchedule(schedule)
setFormData(schedule)
setShowForm(true)
}
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const formatScheduleTime = (schedule: SmartSchedule) => {
const time = `${schedule.hour.toString().padStart(2, '0')}:${schedule.minute.toString().padStart(2, '0')}`
if (schedule.frequency === 'daily') return `Daily at ${time}`
if (schedule.frequency === 'weekly') return `${dayNames[schedule.day_of_week]}s at ${time}`
return `Day ${schedule.day_of_month} of month at ${time}`
}
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 rounded-full border-2 border-transparent border-t-purple-400 animate-spin" />
<span className="ml-2 text-muted-foreground">Loading schedules...</span>
</div>
)
}
return (
<div className="space-y-4">
{/* Global Toggle */}
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div>
<p className="font-medium">Automatic SMART Tests</p>
<p className="text-xs text-muted-foreground">Enable or disable all scheduled tests</p>
</div>
<Button
variant={config.enabled ? "default" : "outline"}
size="sm"
onClick={handleToggleGlobal}
disabled={saving}
className={config.enabled ? "bg-purple-600 hover:bg-purple-700" : ""}
>
{config.enabled ? 'Enabled' : 'Disabled'}
</Button>
</div>
{/* Schedules List */}
{config.schedules.length > 0 ? (
<div className="space-y-2">
<h4 className="font-semibold text-sm">Configured Schedules</h4>
{config.schedules.map(schedule => (
<div
key={schedule.id}
className={`border rounded-lg p-3 ${schedule.active ? 'border-purple-500/30 bg-purple-500/5' : 'border-muted opacity-60'}`}
>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Badge className={schedule.test_type === 'long' ? 'bg-orange-500/10 text-orange-400 border-orange-500/20' : 'bg-blue-500/10 text-blue-400 border-blue-500/20'}>
{schedule.test_type}
</Badge>
<span className="text-sm font-medium">{formatScheduleTime(schedule)}</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
Disks: {schedule.disks.includes('all') ? 'All disks' : schedule.disks.join(', ')} |
Keep {schedule.retention} results
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => editSchedule(schedule)}
className="h-8 w-8 p-0"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSchedule(schedule.id)}
className="h-8 w-8 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/10"
disabled={saving}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 text-muted-foreground">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No scheduled tests configured</p>
<p className="text-xs mt-1">Create a schedule to automatically run SMART tests</p>
</div>
)}
{/* Add/Edit Form */}
{showForm ? (
<div className="border rounded-lg p-4 space-y-4">
<h4 className="font-semibold">{editingSchedule ? 'Edit Schedule' : 'New Schedule'}</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-muted-foreground">Test Type</label>
<select
value={formData.test_type}
onChange={e => setFormData(prev => ({ ...prev, test_type: e.target.value as 'short' | 'long' }))}
className="w-full mt-1 p-2 rounded-md bg-background border border-input text-sm"
>
<option value="short">Short Test (~2 min)</option>
<option value="long">Long Test (1-4 hours)</option>
</select>
</div>
<div>
<label className="text-sm text-muted-foreground">Frequency</label>
<select
value={formData.frequency}
onChange={e => setFormData(prev => ({ ...prev, frequency: e.target.value as 'daily' | 'weekly' | 'monthly' }))}
className="w-full mt-1 p-2 rounded-md bg-background border border-input text-sm"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
{formData.frequency === 'weekly' && (
<div>
<label className="text-sm text-muted-foreground">Day of Week</label>
<select
value={formData.day_of_week}
onChange={e => setFormData(prev => ({ ...prev, day_of_week: parseInt(e.target.value) }))}
className="w-full mt-1 p-2 rounded-md bg-background border border-input text-sm"
>
{dayNames.map((day, i) => (
<option key={day} value={i}>{day}</option>
))}
</select>
</div>
)}
{formData.frequency === 'monthly' && (
<div>
<label className="text-sm text-muted-foreground">Day of Month</label>
<select
value={formData.day_of_month}
onChange={e => setFormData(prev => ({ ...prev, day_of_month: parseInt(e.target.value) }))}
className="w-full mt-1 p-2 rounded-md bg-background border border-input text-sm"
>
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
<option key={day} value={day}>{day}</option>
))}
</select>
</div>
)}
<div>
<label className="text-sm text-muted-foreground">Time (Hour)</label>
<select
value={formData.hour}
onChange={e => setFormData(prev => ({ ...prev, hour: parseInt(e.target.value) }))}
className="w-full mt-1 p-2 rounded-md bg-background border border-input text-sm"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i}>{i.toString().padStart(2, '0')}:00</option>
))}
</select>
</div>
<div>
<label className="text-sm text-muted-foreground">Keep Results</label>
<select
value={formData.retention}
onChange={e => setFormData(prev => ({ ...prev, retention: parseInt(e.target.value) }))}
className="w-full mt-1 p-2 rounded-md bg-background border border-input text-sm"
>
<option value={5}>Last 5</option>
<option value={10}>Last 10</option>
<option value={20}>Last 20</option>
<option value={50}>Last 50</option>
<option value={0}>Keep All</option>
</select>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
onClick={handleSaveSchedule}
disabled={saving}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
{saving ? 'Saving...' : 'Save Schedule'}
</Button>
<Button
variant="outline"
onClick={() => {
setShowForm(false)
setEditingSchedule(null)
resetForm()
}}
>
Cancel
</Button>
</div>
</div>
) : (
<Button
onClick={() => setShowForm(true)}
variant="outline"
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
Add Schedule
</Button>
)}
<p className="text-xs text-muted-foreground text-center">
Scheduled tests run automatically via cron. Results are saved to the SMART history.
</p>
</div>
)
}