2025-10-02 22:29:24 +02:00
"use client"
import { useEffect , useState } from "react"
import { Card , CardContent , CardHeader , CardTitle } from "@/components/ui/card"
2026-04-13 14:49:48 +02:00
import { HardDrive , Database , AlertTriangle , CheckCircle2 , XCircle , Square , Thermometer , Archive , Info , Clock , Usb , Server , Activity , FileText , Play , Loader2 , Download , Plus , Trash2 , Settings } from "lucide-react"
2025-10-02 22:29:24 +02:00
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
2025-10-02 23:20:59 +02:00
import { Dialog , DialogContent , DialogDescription , DialogHeader , DialogTitle } from "@/components/ui/dialog"
2026-04-12 20:32:34 +02:00
import { Button } from "@/components/ui/button"
2025-11-13 17:56:42 +01:00
import { fetchApi } from "../lib/api-config"
2025-10-02 22:29:24 +02:00
interface DiskInfo {
name : string
2025-10-11 00:21:22 +02:00
size? : number // Changed from string to number (KB) for formatMemory()
size_formatted? : string // Added formatted size string for display
2025-10-02 22:29:24 +02:00
temperature : number
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
2025-10-02 23:20:59 +02:00
reallocated_sectors? : number
pending_sectors? : number
crc_errors? : number
2025-10-11 00:21:22 +02:00
rotation_rate? : number
power_cycles? : number
2025-10-14 22:14:48 +02:00
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
2026-02-27 23:49:26 +01:00
io_errors ? : {
count : number
severity : string
sample : string
reason : string
2026-02-28 19:18:13 +01:00
error_type? : string // 'io' | 'filesystem'
2026-02-27 23:49:26 +01:00
}
2026-03-05 17:29:07 +01:00
observations_count? : number
2026-03-05 20:44:51 +01:00
connection_type ? : 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
removable? : boolean
2026-04-12 20:32:34 +02:00
is_system_disk? : boolean
system_usage? : string [ ]
2026-03-05 17:29:07 +01:00
}
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
2025-10-02 22:29:24 +02:00
}
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 [ ]
2025-10-02 23:20:59 +02:00
disk_count : number
healthy_disks : number
warning_disks : number
critical_disks : number
2025-10-02 22:29:24 +02:00
error? : string
}
2025-10-04 17:48:10 +02:00
interface ProxmoxStorage {
name : string
type : string
status : string
total : number
used : number
available : number
percent : number
2025-11-09 23:36:50 +01:00
node : string // Added node property for detailed debug logging
2025-10-04 17:48:10 +02:00
}
interface ProxmoxStorageData {
storage : ProxmoxStorage [ ]
error? : string
}
2025-10-13 15:06:03 +02:00
const formatStorage = ( sizeInGB : number ) : string = > {
if ( sizeInGB < 1 ) {
// Less than 1 GB, show in MB
return ` ${ ( sizeInGB * 1024 ) . toFixed ( 1 ) } MB `
2025-11-04 12:47:26 +01:00
} else if ( sizeInGB > 999 ) {
return ` ${ ( sizeInGB / 1024 ) . toFixed ( 2 ) } TB `
2025-10-13 15:06:03 +02:00
} else {
2025-11-04 12:47:26 +01:00
// Between 1 and 999 GB, show in GB
return ` ${ sizeInGB . toFixed ( 2 ) } GB `
2025-10-13 15:06:03 +02:00
}
}
2025-10-02 22:29:24 +02:00
export function StorageOverview() {
const [ storageData , setStorageData ] = useState < StorageData | null > ( null )
2025-10-04 17:48:10 +02:00
const [ proxmoxStorage , setProxmoxStorage ] = useState < ProxmoxStorageData | null > ( null )
2025-10-02 22:29:24 +02:00
const [ loading , setLoading ] = useState ( true )
2025-10-02 23:20:59 +02:00
const [ selectedDisk , setSelectedDisk ] = useState < DiskInfo | null > ( null )
const [ detailsOpen , setDetailsOpen ] = useState ( false )
2026-03-05 17:29:07 +01:00
const [ diskObservations , setDiskObservations ] = useState < DiskObservation [ ] > ( [ ] )
const [ loadingObservations , setLoadingObservations ] = useState ( false )
2026-04-16 11:43:42 +02:00
const [ activeModalTab , setActiveModalTab ] = useState < "overview" | "smart" | "history" | "schedule" > ( "overview" )
2026-04-13 14:49:48 +02:00
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 )
2025-10-02 22:29:24 +02:00
const fetchStorageData = async ( ) = > {
try {
2025-11-13 17:56:42 +01:00
const [ data , proxmoxData ] = await Promise . all ( [
fetchApi < StorageData > ( "/api/storage" ) ,
fetchApi < ProxmoxStorageData > ( "/api/proxmox-storage" ) ,
2025-10-04 17:48:10 +02:00
] )
2025-10-02 22:29:24 +02:00
setStorageData ( data )
2025-10-04 17:48:10 +02:00
setProxmoxStorage ( proxmoxData )
2025-10-02 22:29:24 +02:00
} catch ( error ) {
console . error ( "Error fetching storage data:" , error )
} finally {
setLoading ( false )
}
}
useEffect ( ( ) = > {
fetchStorageData ( )
2025-10-12 17:24:13 +02:00
const interval = setInterval ( fetchStorageData , 60000 )
2025-10-02 22:29:24 +02:00
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 >
}
}
2025-10-14 20:54:41 +02:00
const getTempColor = ( temp : number , diskName? : string , rotationRate? : number ) = > {
2025-10-02 22:29:24 +02:00
if ( temp === 0 ) return "text-gray-500"
2025-10-14 20:54:41 +02:00
// Determinar el tipo de disco
let diskType = "HDD" // Por defecto
if ( diskName ) {
if ( diskName . startsWith ( "nvme" ) ) {
diskType = "NVMe"
} else if ( ! rotationRate || rotationRate === 0 ) {
diskType = "SSD"
}
}
// Aplicar rangos de temperatura según el tipo
switch ( diskType ) {
case "NVMe" :
2025-10-15 17:04:10 +02:00
// NVMe: ≤70°C verde, 71-80°C amarillo, >80°C rojo
if ( temp <= 70 ) return "text-green-500"
if ( temp <= 80 ) return "text-yellow-500"
2025-10-14 20:54:41 +02:00
return "text-red-500"
case "SSD" :
2025-10-15 17:04:10 +02:00
// SSD: ≤59°C verde, 60-70°C amarillo, >70°C rojo
if ( temp <= 59 ) return "text-green-500"
if ( temp <= 70 ) return "text-yellow-500"
2025-10-14 20:54:41 +02:00
return "text-red-500"
case "HDD" :
default :
// HDD: ≤45°C verde, 46-55°C amarillo, >55°C rojo
if ( temp <= 45 ) return "text-green-500"
if ( temp <= 55 ) return "text-yellow-500"
return "text-red-500"
}
2025-10-02 22:29:24 +02:00
}
2025-10-02 23:20:59 +02:00
const formatHours = ( hours : number ) = > {
if ( hours === 0 ) return "N/A"
const years = Math . floor ( hours / 8760 )
const days = Math . floor ( ( hours % 8760 ) / 24 )
if ( years > 0 ) {
return ` ${ years } y ${ days } d `
}
return ` ${ days } d `
}
2025-10-04 18:36:15 +02:00
const formatRotationRate = ( rpm : number | undefined ) = > {
if ( ! rpm || rpm === 0 ) return "SSD"
return ` ${ rpm . toLocaleString ( ) } RPM `
}
const getDiskType = ( diskName : string , rotationRate : number | undefined ) : string = > {
if ( diskName . startsWith ( "nvme" ) ) {
return "NVMe"
}
2025-11-03 23:26:04 +01:00
// rotation_rate = -1 means HDD but RPM is unknown (detected via kernel rotational flag)
// rotation_rate = 0 or undefined means SSD
// rotation_rate > 0 means HDD with known RPM
if ( rotationRate === - 1 ) {
return "HDD"
}
2025-10-04 18:36:15 +02:00
if ( ! rotationRate || rotationRate === 0 ) {
return "SSD"
}
return "HDD"
}
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 ]
}
2026-03-05 17:29:07 +01:00
const handleDiskClick = async ( disk : DiskInfo ) = > {
2025-10-02 23:20:59 +02:00
setSelectedDisk ( disk )
setDetailsOpen ( true )
2026-03-05 17:29:07 +01:00
setDiskObservations ( [ ] )
2026-04-13 14:49:48 +02:00
setSmartJsonData ( null )
2026-03-05 17:29:07 +01:00
2026-04-13 14:49:48 +02:00
// Fetch observations and SMART JSON data in parallel
2026-03-05 21:28:48 +01:00
setLoadingObservations ( true )
2026-04-13 14:49:48 +02:00
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 )
}
2026-03-05 17:29:07 +01:00
}
2026-04-13 14:49:48 +02:00
// Run both in parallel
await Promise . all ( [ fetchObservations ( ) , fetchSmartJson ( ) ] )
2026-03-05 17:29:07 +01:00
}
const formatObsDate = ( iso : string ) = > {
if ( ! iso ) return 'N/A'
try {
const d = new Date ( iso )
2026-03-06 12:06:53 +01:00
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 } `
2026-03-05 17:29:07 +01:00
} catch { return iso }
2025-10-02 23:20:59 +02:00
}
2026-03-05 17:29:07 +01:00
const obsTypeLabel = ( t : string ) = >
2026-03-05 19:25:05 +01:00
( { 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 )
2026-03-05 17:29:07 +01:00
2025-10-04 17:48:10 +02:00
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" ,
}
return typeColors [ type . toLowerCase ( ) ] || "bg-gray-500/10 text-gray-500 border-gray-500/20"
}
2025-10-14 21:31:15 +02:00
const getStatusIcon = ( status : string ) = > {
switch ( status . toLowerCase ( ) ) {
case "active" :
case "online" :
return < CheckCircle2 className = "h-5 w-5 text-green-500" / >
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" / >
}
}
2025-10-14 22:14:48 +02:00
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
// Si el desgaste es 0, no podemos calcular
if ( wearPercent === 0 ) {
return "N/A"
}
// 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 `
}
2025-10-15 18:41:03 +02:00
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 ) {
// Si no hay temperatura, considerarlo normal
normal ++
return
}
const diskType = getDiskType ( disk . name , disk . rotation_rate )
switch ( diskType ) {
case "NVMe" :
if ( disk . temperature <= 70 ) normal ++
else if ( disk . temperature <= 80 ) warning ++
else critical ++
break
case "SSD" :
if ( disk . temperature <= 59 ) normal ++
else if ( disk . temperature <= 70 ) warning ++
else critical ++
break
case "HDD" :
default :
if ( disk . temperature <= 45 ) normal ++
else if ( disk . temperature <= 55 ) warning ++
else critical ++
break
}
} )
return { normal , warning , critical }
}
2025-10-15 18:56:02 +02:00
const getDiskTypesBreakdown = ( ) = > {
if ( ! storageData || ! storageData . disks ) {
2026-03-05 20:44:51 +01:00
return { nvme : 0 , ssd : 0 , hdd : 0 , usb : 0 }
2025-10-15 18:56:02 +02:00
}
let nvme = 0
let ssd = 0
let hdd = 0
2026-03-05 20:44:51 +01:00
let usb = 0
2025-10-15 18:56:02 +02:00
storageData . disks . forEach ( ( disk ) = > {
2026-03-05 20:44:51 +01:00
if ( disk . connection_type === 'usb' ) {
usb ++
return
}
2025-10-15 18:56:02 +02:00
const diskType = getDiskType ( disk . name , disk . rotation_rate )
if ( diskType === "NVMe" ) nvme ++
else if ( diskType === "SSD" ) ssd ++
else if ( diskType === "HDD" ) hdd ++
} )
2026-03-05 20:44:51 +01:00
return { nvme , ssd , hdd , usb }
2025-10-15 18:56:02 +02:00
}
2025-10-15 19:06:33 +02:00
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"
}
2025-11-10 17:38:46 +01:00
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"
}
2025-10-15 18:41:03 +02:00
const diskHealthBreakdown = getDiskHealthBreakdown ( )
2025-10-15 18:56:02 +02:00
const diskTypesBreakdown = getDiskTypesBreakdown ( )
2025-10-15 18:41:03 +02:00
2025-11-10 17:25:22 +01:00
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 =
2025-11-09 23:59:21 +01:00
proxmoxStorage ? . storage
. filter (
( storage ) = >
storage &&
storage . name &&
storage . status === "active" &&
storage . total > 0 &&
storage . used >= 0 &&
2025-11-10 17:25:22 +01:00
storage . available >= 0 &&
remoteStorageTypes . includes ( storage . type . toLowerCase ( ) ) ,
2025-11-09 23:59:21 +01:00
)
. reduce ( ( sum , storage ) = > sum + storage . used , 0 ) || 0
2025-11-10 17:25:22 +01:00
const totalRemoteCapacity =
2025-11-09 23:59:21 +01:00
proxmoxStorage ? . storage
. filter (
( storage ) = >
storage &&
storage . name &&
storage . status === "active" &&
storage . total > 0 &&
storage . used >= 0 &&
2025-11-10 17:25:22 +01:00
storage . available >= 0 &&
remoteStorageTypes . includes ( storage . type . toLowerCase ( ) ) ,
2025-11-09 23:59:21 +01:00
)
. reduce ( ( sum , storage ) = > sum + storage . total , 0 ) || 0
2025-11-10 17:25:22 +01:00
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
2025-10-16 19:57:55 +02:00
2025-10-02 22:29:24 +02:00
if ( loading ) {
return (
2026-02-16 12:11:37 +01:00
< 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 >
2025-10-02 22:29:24 +02:00
< / 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 */ }
2025-10-22 19:50:26 +02:00
< div className = "grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6" >
2025-10-02 22:29:24 +02:00
< Card >
< CardHeader className = "flex flex-row items-center justify-between space-y-0 pb-2" >
< CardTitle className = "text-sm font-medium" > Total Storage < / CardTitle >
< HardDrive className = "h-4 w-4 text-muted-foreground" / >
< / CardHeader >
< CardContent >
2025-10-22 19:50:26 +02:00
< div className = "text-xl lg:text-2xl font-bold" > { storageData . total . toFixed ( 1 ) } TB < / div >
2025-10-02 23:20:59 +02:00
< p className = "text-xs text-muted-foreground mt-1" > { storageData . disk_count } physical disks < / p >
2025-10-02 22:29:24 +02:00
< / CardContent >
< / Card >
< Card >
< CardHeader className = "flex flex-row items-center justify-between space-y-0 pb-2" >
2025-11-10 17:38:46 +01:00
< CardTitle className = "text-sm font-medium" > Local Used < / CardTitle >
2025-10-02 22:29:24 +02:00
< Database className = "h-4 w-4 text-muted-foreground" / >
< / CardHeader >
< CardContent >
2025-11-10 17:45:40 +01:00
< div className = "text-xl lg:text-2xl font-bold" > { formatStorage ( totalLocalUsed ) } < / div >
2025-11-10 17:38:46 +01:00
< p className = "text-xs mt-1" >
< span className = { getUsageColor ( Number . parseFloat ( localUsagePercent ) ) } > { localUsagePercent } % < / span >
< span className = "text-muted-foreground" > of < / span >
< span className = "text-green-500" > { formatStorage ( totalLocalCapacity ) } < / span >
2025-11-10 17:25:22 +01:00
< / p >
2025-10-02 22:29:24 +02:00
< / CardContent >
< / Card >
< Card >
< CardHeader className = "flex flex-row items-center justify-between space-y-0 pb-2" >
2025-11-10 17:38:46 +01:00
< CardTitle className = "text-sm font-medium" > Remote Used < / CardTitle >
2025-11-10 17:25:22 +01:00
< Archive className = "h-4 w-4 text-muted-foreground" / >
2025-10-02 23:20:59 +02:00
< / CardHeader >
< CardContent >
2025-11-10 17:45:40 +01:00
< div className = "text-xl lg:text-2xl font-bold" >
2025-11-10 17:25:22 +01:00
{ remoteStorageCount > 0 ? formatStorage ( totalRemoteUsed ) : "None" }
< / div >
2025-11-10 17:38:46 +01:00
< p className = "text-xs mt-1" >
{ remoteStorageCount > 0 ? (
< >
< span className = { getUsageColor ( Number . parseFloat ( remoteUsagePercent ) ) } > { remoteUsagePercent } % < / span >
< span className = "text-muted-foreground" > of < / span >
< span className = "text-green-500" > { formatStorage ( totalRemoteCapacity ) } < / span >
< / >
) : (
< span className = "text-muted-foreground" > No remote storage < / span >
) }
2025-10-02 23:20:59 +02:00
< / p >
< / CardContent >
< / Card >
2025-10-15 18:56:02 +02:00
< Card >
< CardHeader className = "flex flex-row items-center justify-between space-y-0 pb-2" >
2025-11-10 17:25:22 +01:00
< CardTitle className = "text-sm font-medium" > Physical Disks < / CardTitle >
2025-10-15 18:56:02 +02:00
< HardDrive className = "h-4 w-4 text-muted-foreground" / >
< / CardHeader >
< CardContent >
2025-10-22 19:50:26 +02:00
< div className = "text-xl lg:text-2xl font-bold" > { storageData . disk_count } disks < / div >
2025-11-10 17:25:22 +01:00
< div className = "space-y-1 mt-1" >
< p className = "text-xs" >
{ diskTypesBreakdown . nvme > 0 && < span className = "text-purple-500" > { diskTypesBreakdown . nvme } NVMe < / span > }
{ diskTypesBreakdown . ssd > 0 && (
< >
{ diskTypesBreakdown . nvme > 0 && ", " }
< span className = "text-cyan-500" > { diskTypesBreakdown . ssd } SSD < / span >
< / >
) }
{ diskTypesBreakdown . hdd > 0 && (
< >
{ ( diskTypesBreakdown . nvme > 0 || diskTypesBreakdown . ssd > 0 ) && ", " }
< span className = "text-blue-500" > { diskTypesBreakdown . hdd } HDD < / span >
< / >
) }
2026-03-05 20:44:51 +01:00
{ diskTypesBreakdown . usb > 0 && (
< >
{ ( diskTypesBreakdown . nvme > 0 || diskTypesBreakdown . ssd > 0 || diskTypesBreakdown . hdd > 0 ) && ", " }
< span className = "text-orange-400" > { diskTypesBreakdown . usb } USB < / span >
< / >
) }
2025-11-10 17:25:22 +01:00
< / p >
< p className = "text-xs" >
< span className = "text-green-500" > { diskHealthBreakdown . normal } normal < / span >
{ diskHealthBreakdown . warning > 0 && (
< >
{ ", " }
< span className = "text-yellow-500" > { diskHealthBreakdown . warning } warning < / span >
< / >
) }
{ diskHealthBreakdown . critical > 0 && (
< >
{ ", " }
< span className = "text-red-500" > { diskHealthBreakdown . critical } critical < / span >
< / >
) }
< / p >
< / div >
2025-10-15 18:56:02 +02:00
< / CardContent >
< / Card >
2025-10-02 22:29:24 +02:00
< / div >
2025-10-04 17:48:10 +02:00
{ proxmoxStorage && proxmoxStorage . storage && proxmoxStorage . storage . length > 0 && (
2025-10-04 17:34:07 +02:00
< Card >
< CardHeader >
< CardTitle className = "flex items-center gap-2" >
< Database className = "h-5 w-5" / >
2025-10-04 17:48:10 +02:00
Proxmox Storage
2025-10-04 17:34:07 +02:00
< / CardTitle >
< / CardHeader >
< CardContent >
< div className = "space-y-4" >
2025-10-16 21:19:03 +02:00
{ proxmoxStorage . storage
2025-11-27 12:34:51 +01:00
. filter ( ( storage ) = > storage && storage . name && storage . used >= 0 && storage . available >= 0 )
2025-10-23 17:21:48 +02:00
. sort ( ( a , b ) = > a . name . localeCompare ( b . name ) )
2026-03-19 19:07:26 +01:00
. map ( ( storage ) = > {
// Check if storage is excluded from monitoring
const isExcluded = storage . excluded === true
const hasError = storage . status === "error" && ! isExcluded
return (
2025-11-27 12:17:52 +01:00
< div
key = { storage . name }
className = { ` border rounded-lg p-4 ${
2026-03-19 19:07:26 +01:00
hasError
? "border-red-500/50 bg-red-500/5"
: isExcluded
? "border-purple-500/30 bg-purple-500/5 opacity-75"
: ""
2025-11-27 12:17:52 +01:00
} ` }
>
2025-10-16 21:19:03 +02:00
< 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 >
2026-03-19 19:07:26 +01:00
{ isExcluded && (
< Badge className = "bg-purple-500/10 text-purple-400 border-purple-500/20 text-[10px]" >
excluded
< / Badge >
) }
2025-10-16 21:19:03 +02:00
< / div >
2025-10-04 17:48:10 +02:00
2025-10-16 21:19:03 +02:00
< 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 >
< h3 className = "font-semibold text-base flex-1 min-w-0 truncate" > { storage . name } < / h3 >
2026-03-19 19:07:26 +01:00
{ isExcluded ? (
< Badge className = "bg-purple-500/10 text-purple-400 border-purple-500/20 text-[10px]" >
excluded
< / Badge >
) : (
getStatusIcon ( storage . status )
) }
2025-10-04 17:48:10 +02:00
< / div >
2025-10-16 21:19:03 +02:00
{ /* Desktop: Badge active + Porcentaje */ }
< div className = "hidden md:flex items-center gap-2" >
< Badge
className = {
2026-03-19 19:07:26 +01:00
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 === "error"
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
2025-10-16 21:19:03 +02:00
}
2025-10-04 17:48:10 +02:00
>
2026-03-19 19:07:26 +01:00
{ isExcluded ? "not monitored" : storage . status }
2025-10-16 21:19:03 +02:00
< / Badge >
< span className = "text-sm font-medium" > { storage . percent } % < / span >
2025-10-04 17:34:07 +02:00
< / div >
2025-10-16 21:19:03 +02:00
< / 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 >
2025-11-04 12:47:26 +01:00
< p className = "font-medium" > { formatStorage ( storage . total ) } < / p >
2025-10-16 21:19:03 +02:00
< / 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"
} ` }
>
2025-11-04 12:47:26 +01:00
{ formatStorage ( storage . used ) }
2025-10-16 21:19:03 +02:00
< / p >
< / div >
< div >
< p className = "text-muted-foreground" > Available < / p >
2025-11-04 12:47:26 +01:00
< p className = "font-medium text-green-400" > { formatStorage ( storage . available ) } < / p >
2025-10-16 21:19:03 +02:00
< / div >
2025-10-04 17:48:10 +02:00
< / div >
< / div >
2025-10-04 17:34:07 +02:00
< / div >
2026-03-19 19:07:26 +01:00
)
} ) }
2025-10-04 17:34:07 +02:00
< / div >
< / CardContent >
< / Card >
) }
2025-10-02 22:29:24 +02:00
{ /* 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 >
2025-10-23 17:21:48 +02:00
< p className = "text-sm text-muted-foreground" > Size < / p >
2025-10-02 22:29:24 +02:00
< p className = "font-medium" > { pool . size } < / p >
< / div >
< div >
2025-10-23 17:21:48 +02:00
< p className = "text-sm text-muted-foreground" > Allocated < / p >
2025-10-02 22:29:24 +02:00
< p className = "font-medium" > { pool . allocated } < / p >
< / div >
< div >
2025-10-23 17:21:48 +02:00
< p className = "text-sm text-muted-foreground" > Free < / p >
2025-10-02 22:29:24 +02:00
< p className = "font-medium" > { pool . free } < / p >
< / div >
< / div >
< / div >
) ) }
< / div >
< / CardContent >
< / Card >
) }
2026-03-05 20:44:51 +01:00
{ /* Physical Disks (internal only) */ }
2025-10-02 22:29:24 +02:00
< Card >
< CardHeader >
< CardTitle className = "flex items-center gap-2" >
< HardDrive className = "h-5 w-5" / >
Physical Disks & SMART Status
< / CardTitle >
< / CardHeader >
< CardContent >
< div className = "space-y-4" >
2026-03-05 20:44:51 +01:00
{ storageData . disks . filter ( d = > d . connection_type !== 'usb' ) . map ( ( disk ) = > (
2025-10-17 19:06:15 +02:00
< div key = { disk . name } >
< div
className = "sm:hidden border border-white/10 rounded-lg p-4 cursor-pointer bg-white/5 transition-colors"
onClick = { ( ) = > handleDiskClick ( disk ) }
>
< div className = "space-y-2 mb-3" >
{ /* Row 1: Device name and type badge */ }
2026-04-12 20:32:34 +02:00
< div className = "flex items-center gap-2 flex-wrap" >
2025-10-17 19:06:15 +02:00
< HardDrive className = "h-5 w-5 text-muted-foreground flex-shrink-0" / >
< h3 className = "font-semibold" > / dev / { disk . name } < / h3 >
< Badge className = { getDiskTypeBadge ( disk . name , disk . rotation_rate ) . className } >
{ getDiskTypeBadge ( disk . name , disk . rotation_rate ) . label }
< / Badge >
2026-04-12 20:32:34 +02:00
{ 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 >
) }
2025-10-17 19:06:15 +02:00
< / div >
2025-10-04 18:53:31 +02:00
2025-10-17 19:06:15 +02:00
{ /* Row 2: Model, temperature, and health status */ }
< div className = "flex items-center justify-between gap-3 pl-7" >
{ disk . model && disk . model !== "Unknown" && (
< p className = "text-sm text-muted-foreground truncate flex-1 min-w-0" > { disk . model } < / p >
2025-10-04 18:53:31 +02:00
) }
2025-10-17 19:06:15 +02:00
< div className = "flex items-center gap-3 flex-shrink-0" >
{ disk . temperature > 0 && (
< 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 >
) }
2026-03-06 15:24:18 +01:00
{ ( disk . observations_count ? ? 0 ) > 0 && (
< Badge className = "bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1" >
2026-03-05 17:29:07 +01:00
< Info className = "h-3 w-3" / >
2026-03-06 15:24:18 +01:00
{ disk . observations_count } obs .
2026-03-05 17:29:07 +01:00
< / Badge >
) }
2025-10-17 19:06:15 +02:00
{ getHealthBadge ( disk . health ) }
< / div >
2025-10-04 18:53:31 +02:00
< / div >
2025-10-02 22:29:24 +02:00
< / div >
2025-10-17 19:06:15 +02:00
2026-02-28 19:18:13 +01:00
{ disk . io_errors && disk . io_errors . count > 0 && (
2026-02-27 23:49:26 +01:00
< div className = { ` flex items-start gap-2 p-2 rounded text-xs ${
disk . io_errors . severity === 'CRITICAL'
? 'bg-red-500/10 text-red-400 border border-red-500/20'
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
} ` }>
< AlertTriangle className = "h-3.5 w-3.5 flex-shrink-0 mt-0.5" / >
2026-02-28 19:18:13 +01:00
< 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 >
2026-02-27 23:49:26 +01:00
< / div >
2026-02-28 19:18:13 +01:00
) }
< div className = "grid grid-cols-2 gap-4 text-sm" >
{ disk . size_formatted && (
2025-10-17 19:06:15 +02:00
< div >
< p className = "text-sm text-muted-foreground" > Size < / p >
< p className = "font-medium" > { disk . size_formatted } < / p >
< / div >
) }
{ disk . smart_status && disk . smart_status !== "unknown" && (
< div >
< p className = "text-sm text-muted-foreground" > SMART Status < / p >
< p className = "font-medium capitalize" > { disk . smart_status } < / p >
< / div >
) }
{ disk . power_on_hours !== undefined && disk . power_on_hours > 0 && (
< div >
< p className = "text-sm text-muted-foreground" > Power On Time < / p >
< p className = "font-medium" > { formatHours ( disk . power_on_hours ) } < / p >
< / div >
) }
{ disk . serial && disk . serial !== "Unknown" && (
< div >
< p className = "text-sm text-muted-foreground" > Serial < / p >
< p className = "font-medium text-xs" > { disk . serial } < / p >
< / div >
) }
< / div >
2025-10-02 22:29:24 +02:00
< / div >
2025-10-17 19:06:15 +02:00
< div
className = "hidden sm:block border border-white/10 rounded-lg p-4 cursor-pointer bg-card hover:bg-white/5 transition-colors"
onClick = { ( ) = > handleDiskClick ( disk ) }
>
< div className = "space-y-2 mb-3" >
{ /* Row 1: Device name and type badge */ }
< div className = "flex items-center gap-2" >
< HardDrive className = "h-5 w-5 text-muted-foreground flex-shrink-0" / >
< h3 className = "font-semibold" > / dev / { disk . name } < / h3 >
< Badge className = { getDiskTypeBadge ( disk . name , disk . rotation_rate ) . className } >
{ getDiskTypeBadge ( disk . name , disk . rotation_rate ) . label }
< / Badge >
2026-04-12 20:32:34 +02:00
{ 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 >
) }
2025-10-02 22:29:24 +02:00
< / div >
2025-10-17 19:06:15 +02:00
{ /* Row 2: Model, temperature, and health status */ }
< div className = "flex items-center justify-between gap-3 pl-7" >
{ disk . model && disk . model !== "Unknown" && (
< p className = "text-sm text-muted-foreground truncate flex-1 min-w-0" > { disk . model } < / p >
) }
< div className = "flex items-center gap-3 flex-shrink-0" >
{ disk . temperature > 0 && (
< 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 >
) }
2026-03-06 15:24:18 +01:00
{ ( disk . observations_count ? ? 0 ) > 0 && (
< Badge className = "bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1" >
2026-03-05 17:29:07 +01:00
< Info className = "h-3 w-3" / >
2026-03-06 15:24:18 +01:00
{ disk . observations_count } obs .
2026-03-05 17:29:07 +01:00
< / Badge >
) }
2025-10-17 19:06:15 +02:00
{ getHealthBadge ( disk . health ) }
< / div >
2025-10-02 22:29:24 +02:00
< / div >
2025-10-17 19:06:15 +02:00
< / div >
2026-02-27 23:49:26 +01:00
{ disk . io_errors && disk . io_errors . count > 0 && (
< div className = { ` flex items-start gap-2 p-2 rounded text-xs ${
disk . io_errors . severity === 'CRITICAL'
? 'bg-red-500/10 text-red-400 border border-red-500/20'
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
} ` }>
< AlertTriangle className = "h-3.5 w-3.5 flex-shrink-0 mt-0.5" / >
< div >
2026-02-28 19:18:13 +01:00
{ disk . io_errors . error_type === 'filesystem' ? (
< >
< span className = "font-medium" > Filesystem corruption detected < / span >
{ disk . io_errors . reason && (
< p className = "mt-0.5 opacity-90 whitespace-pre-line" > { disk . io_errors . reason } < / p >
) }
< / >
) : (
< >
< span className = "font-medium" > { disk . io_errors . count } I / O error { disk . io_errors . count !== 1 ? 's' : '' } in 5 min < / span >
{ disk . io_errors . sample && (
< p className = "mt-0.5 opacity-80 font-mono truncate max-w-md" > { disk . io_errors . sample } < / p >
) }
< / >
2026-02-27 23:49:26 +01:00
) }
< / div >
< / div >
) }
2025-10-17 19:06:15 +02:00
< div className = "grid grid-cols-2 md:grid-cols-4 gap-4 text-sm" >
{ disk . size_formatted && (
< div >
< p className = "text-sm text-muted-foreground" > Size < / p >
< p className = "font-medium" > { disk . size_formatted } < / p >
< / div >
) }
{ disk . smart_status && disk . smart_status !== "unknown" && (
< div >
< p className = "text-sm text-muted-foreground" > SMART Status < / p >
< p className = "font-medium capitalize" > { disk . smart_status } < / p >
< / div >
) }
{ disk . power_on_hours !== undefined && disk . power_on_hours > 0 && (
< div >
< p className = "text-sm text-muted-foreground" > Power On Time < / p >
< p className = "font-medium" > { formatHours ( disk . power_on_hours ) } < / p >
< / div >
) }
{ disk . serial && disk . serial !== "Unknown" && (
< div >
< p className = "text-sm text-muted-foreground" > Serial < / p >
2026-03-08 23:22:18 +01:00
< p className = "font-medium text-xs" > { disk . serial . replace ( /\\x[0-9a-fA-F]{2}/g , '' ) } < / p >
2025-10-17 19:06:15 +02:00
< / div >
) }
< / div >
2025-10-02 22:29:24 +02:00
< / div >
< / div >
) ) }
< / div >
< / CardContent >
< / Card >
2025-10-02 23:20:59 +02:00
2026-03-05 20:44:51 +01:00
{ /* 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 = "space-y-4" >
{ storageData . disks . filter ( d = > d . connection_type === 'usb' ) . map ( ( disk ) = > (
< div key = { disk . name } >
{ /* Mobile card */ }
< div
className = "sm:hidden border border-white/10 rounded-lg p-4 cursor-pointer bg-white/5 transition-colors"
onClick = { ( ) = > handleDiskClick ( disk ) }
>
2026-03-08 22:47:04 +01:00
< div className = "space-y-2 mb-3" >
< div className = "flex items-center gap-2" >
< Usb className = "h-5 w-5 text-orange-400 flex-shrink-0" / >
< h3 className = "font-semibold" > / dev / { disk . name } < / h3 >
< Badge className = "bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5" > USB < / Badge >
< / div >
< div className = "flex items-center justify-between gap-3 pl-7" >
{ disk . model && disk . model !== "Unknown" && (
< p className = "text-sm text-muted-foreground truncate flex-1 min-w-0" > { disk . model } < / p >
) }
< div className = "flex items-center gap-3 flex-shrink-0" >
2026-03-05 20:44:51 +01:00
{ disk . temperature > 0 && (
< div className = "flex items-center gap-1" >
2026-03-08 22:47:04 +01:00
< 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 ) } ` } >
2026-03-05 20:44:51 +01:00
{ disk . temperature } ° C
< / span >
< / div >
) }
2026-03-08 22:47:04 +01:00
{ ( disk . observations_count ? ? 0 ) > 0 && (
< Badge className = "bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1 text-[10px] px-1.5 py-0" >
< Info className = "h-3 w-3" / >
{ disk . observations_count }
< / Badge >
) }
2026-03-08 23:22:18 +01:00
{ getHealthBadge ( disk . health ) }
2026-03-05 20:44:51 +01:00
< / div >
< / div >
< / div >
2026-03-08 23:22:18 +01:00
{ /* USB Mobile: Size, SMART, Serial grid */ }
< div className = "grid grid-cols-2 gap-4 text-sm" >
{ disk . size_formatted && (
< div >
< p className = "text-sm text-muted-foreground" > Size < / p >
< p className = "font-medium" > { disk . size_formatted } < / p >
< / div >
) }
{ disk . smart_status && disk . smart_status !== "unknown" && (
< div >
< p className = "text-sm text-muted-foreground" > SMART Status < / p >
< p className = "font-medium capitalize" > { disk . smart_status } < / p >
< / div >
) }
{ disk . serial && disk . serial !== "Unknown" && (
< div >
< p className = "text-sm text-muted-foreground" > Serial < / p >
< p className = "font-medium text-xs" > { disk . serial . replace ( /\\x[0-9a-fA-F]{2}/g , '' ) } < / p >
< / div >
) }
< / div >
< / div >
2026-03-05 20:44:51 +01:00
2026-03-08 23:22:18 +01:00
{ /* Desktop */ }
< div
className = "hidden sm:block border border-white/10 rounded-lg p-4 cursor-pointer hover:bg-white/5 transition-colors"
onClick = { ( ) = > handleDiskClick ( disk ) }
2026-03-05 20:44:51 +01:00
>
< div className = "flex items-center justify-between mb-3" >
< div className = "flex items-center gap-2" >
< Usb className = "h-5 w-5 text-orange-400" / >
< h3 className = "font-semibold" > / dev / { disk . name } < / h3 >
< Badge className = "bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5" > USB < / Badge >
< / div >
< div className = "flex items-center gap-3" >
{ disk . temperature > 0 && (
< 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 >
) }
{ getHealthBadge ( disk . health ) }
2026-03-06 15:24:18 +01:00
{ ( disk . observations_count ? ? 0 ) > 0 && (
< Badge className = "bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1" >
2026-03-05 20:44:51 +01:00
< Info className = "h-3 w-3" / >
2026-03-06 15:24:18 +01:00
{ disk . observations_count } obs .
2026-03-05 20:44:51 +01:00
< / Badge >
) }
< / div >
< / div >
{ disk . model && disk . model !== "Unknown" && (
< p className = "text-sm text-muted-foreground mb-3 ml-7" > { disk . model } < / p >
) }
{ disk . io_errors && disk . io_errors . count > 0 && (
< div className = { ` flex items-start gap-2 p-2 rounded text-xs mb-3 ${
disk . io_errors . severity === 'CRITICAL'
? 'bg-red-500/10 text-red-400 border border-red-500/20'
: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'
} ` }>
< AlertTriangle className = "h-3.5 w-3.5 flex-shrink-0 mt-0.5" / >
< div >
{ disk . io_errors . error_type === 'filesystem' ? (
< >
< span className = "font-medium" > Filesystem corruption detected < / span >
{ disk . io_errors . reason && (
< p className = "mt-0.5 opacity-90 whitespace-pre-line" > { disk . io_errors . reason } < / p >
) }
< / >
) : (
< >
< span className = "font-medium" > { disk . io_errors . count } I / O error { disk . io_errors . count !== 1 ? 's' : '' } in 5 min < / span >
{ disk . io_errors . sample && (
< p className = "mt-0.5 opacity-80 font-mono truncate max-w-md" > { disk . io_errors . sample } < / p >
) }
< / >
) }
< / div >
< / div >
) }
< div className = "grid grid-cols-2 md:grid-cols-4 gap-4 text-sm" >
{ disk . size_formatted && (
< div >
< p className = "text-sm text-muted-foreground" > Size < / p >
< p className = "font-medium" > { disk . size_formatted } < / p >
< / div >
) }
{ disk . smart_status && disk . smart_status !== "unknown" && (
< div >
< p className = "text-sm text-muted-foreground" > SMART Status < / p >
< p className = "font-medium capitalize" > { disk . smart_status } < / p >
< / div >
) }
{ disk . power_on_hours !== undefined && disk . power_on_hours > 0 && (
< div >
< p className = "text-sm text-muted-foreground" > Power On Time < / p >
< p className = "font-medium" > { formatHours ( disk . power_on_hours ) } < / p >
< / div >
) }
{ disk . serial && disk . serial !== "Unknown" && (
< div >
< p className = "text-sm text-muted-foreground" > Serial < / p >
2026-03-08 23:22:18 +01:00
< p className = "font-medium text-xs" > { disk . serial . replace ( /\\x[0-9a-fA-F]{2}/g , '' ) } < / p >
2026-03-05 20:44:51 +01:00
< / div >
) }
< / div >
< / div >
< / div >
) ) }
< / div >
< / CardContent >
< / Card >
) }
2025-10-02 23:20:59 +02:00
{ /* Disk Details Dialog */ }
2026-04-12 20:32:34 +02:00
< Dialog open = { detailsOpen } onOpenChange = { ( open ) = > {
setDetailsOpen ( open )
2026-04-13 14:49:48 +02:00
if ( ! open ) {
setActiveModalTab ( "overview" )
setSmartJsonData ( null )
}
2026-04-12 20:32:34 +02:00
} } >
< DialogContent className = "max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0" >
< DialogHeader className = "px-6 pt-6 pb-0" >
2025-10-02 23:20:59 +02:00
< DialogTitle className = "flex items-center gap-2" >
2026-03-05 20:44:51 +01:00
{ selectedDisk ? . connection_type === 'usb' ? (
< Usb className = "h-5 w-5 text-orange-400" / >
) : (
< HardDrive className = "h-5 w-5" / >
) }
2025-10-02 23:20:59 +02:00
Disk Details : /dev/ { selectedDisk ? . name }
2026-03-05 20:44:51 +01:00
{ selectedDisk ? . connection_type === 'usb' && (
< Badge className = "bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5" > USB < / Badge >
) }
2026-04-12 20:32:34 +02:00
{ 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 >
) }
2025-10-02 23:20:59 +02:00
< / DialogTitle >
2026-04-12 20:32:34 +02:00
< DialogDescription >
{ selectedDisk ? . model !== "Unknown" ? selectedDisk ? . model : "Physical disk" } - { selectedDisk ? . size_formatted }
< / DialogDescription >
2025-10-02 23:20:59 +02:00
< / DialogHeader >
2026-04-12 20:32:34 +02:00
{ /* Tab Navigation */ }
2026-04-16 15:52:26 +02:00
< div className = "flex border-b border-border px-6 overflow-x-auto" >
2026-04-12 20:32:34 +02:00
< button
onClick = { ( ) = > setActiveModalTab ( "overview" ) }
2026-04-16 15:52:26 +02:00
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 ${
2026-04-12 20:32:34 +02:00
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" ) }
2026-04-16 15:52:26 +02:00
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 ${
2026-04-12 20:32:34 +02:00
activeModalTab === "smart"
? "border-green-500 text-green-500"
: "border-transparent text-muted-foreground hover:text-foreground"
} ` }
>
< Activity className = "h-4 w-4" / >
2026-04-16 15:28:48 +02:00
SMART
2026-04-12 20:32:34 +02:00
< / button >
2026-04-16 11:43:42 +02:00
< button
onClick = { ( ) = > setActiveModalTab ( "history" ) }
2026-04-16 15:52:26 +02:00
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 ${
2026-04-16 11:43:42 +02:00
activeModalTab === "history"
? "border-orange-500 text-orange-500"
: "border-transparent text-muted-foreground hover:text-foreground"
} ` }
>
< Archive className = "h-4 w-4" / >
History
< / button >
2026-04-13 14:49:48 +02:00
< button
onClick = { ( ) = > setActiveModalTab ( "schedule" ) }
2026-04-16 15:52:26 +02:00
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 ${
2026-04-13 14:49:48 +02:00
activeModalTab === "schedule"
? "border-purple-500 text-purple-500"
: "border-transparent text-muted-foreground hover:text-foreground"
} ` }
>
< Clock className = "h-4 w-4" / >
Schedule
< / button >
2026-04-12 20:32:34 +02:00
< / div >
{ /* Tab Content */ }
< div className = "flex-1 overflow-y-auto px-6 py-4 min-h-0" >
{ selectedDisk && activeModalTab === "overview" && (
2025-10-02 23:20:59 +02:00
< 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 >
2026-03-08 23:22:18 +01:00
< p className = "font-medium" > { selectedDisk . serial ? . replace ( /\\x[0-9a-fA-F]{2}/g , '' ) || 'Unknown' } < / p >
2025-10-02 23:20:59 +02:00
< / div >
< div >
< p className = "text-sm text-muted-foreground" > Capacity < / p >
2025-10-11 00:21:22 +02:00
< p className = "font-medium" > { selectedDisk . size_formatted } < / p >
2025-10-02 23:20:59 +02:00
< / div >
< div >
< p className = "text-sm text-muted-foreground" > Health Status < / p >
2026-03-05 17:29:07 +01:00
< div className = "flex items-center gap-2 mt-1" >
{ getHealthBadge ( selectedDisk . health ) }
2026-03-06 15:24:18 +01:00
{ ( 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 >
2026-03-05 17:29:07 +01:00
) }
< / div >
2025-10-02 23:20:59 +02:00
< / div >
< / div >
2026-04-16 12:08:36 +02:00
{ /* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */ }
2026-04-16 11:43:42 +02:00
{ ( ( ) = > {
let wearUsed : number | null = null
let lifeRemaining : number | null = null
let estimatedLife = ''
let dataWritten = ''
let spare : number | undefined
2026-04-16 12:08:36 +02:00
// --- Step 1: DiskInfo = primary source (refreshed every 60s, always fresh) ---
// Works for NVMe (percentage_used) and SSD (media_wearout_indicator, ssd_life_left)
2026-04-16 11:43:42 +02:00
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 `
}
}
2026-04-16 12:08:36 +02:00
// --- Step 2: SMART test JSON — primary for SSD, supplement for NVMe ---
2026-04-16 11:43:42 +02:00
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 `
2026-04-13 19:11:57 +02:00
}
2026-04-16 11:43:42 +02:00
}
}
}
}
// --- Only render if we have meaningful wear data ---
if ( wearUsed === null && lifeRemaining === null ) return null
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
2026-04-16 15:09:16 +02:00
{ smartJsonData ? . has_data && ! wi && (
2026-04-16 11:43:42 +02:00
< Badge className = "bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5" > Real Test < / Badge >
) }
< / h4 >
2026-04-16 15:09:16 +02:00
< div className = "flex gap-5 items-start" >
2026-04-16 11:43:42 +02:00
{ lifeRemaining !== null && (
< div className = "flex flex-col items-center gap-1 flex-shrink-0" >
2026-04-16 15:09:16 +02:00
< 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 >
2026-04-17 10:38:39 +02:00
< text x = "44" y = "56" textAnchor = "middle" fill = "currentColor" fontSize = "12" className = "text-muted-foreground" > life < / text >
2026-04-16 11:43:42 +02:00
< / svg >
2025-10-14 22:14:48 +02:00
< / div >
2026-04-16 11:43:42 +02:00
) }
< div className = "flex-1 space-y-3 min-w-0" >
{ wearUsed !== null && (
2025-10-14 22:14:48 +02:00
< div >
2026-04-16 11:43:42 +02:00
< 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 >
2026-04-17 10:38:39 +02:00
< Progress value = { wearUsed } className = "h-2 [&>div]:bg-blue-500" / >
2025-10-14 22:14:48 +02:00
< / div >
) }
2026-04-16 11:43:42 +02:00
< div className = "grid grid-cols-2 gap-3" >
{ estimatedLife && (
< 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 >
2025-10-14 22:14:48 +02:00
< / div >
2026-04-16 11:43:42 +02:00
< / div >
2025-10-14 22:14:48 +02:00
< / div >
2026-04-16 11:43:42 +02:00
)
} ) ( ) }
2025-10-14 22:14:48 +02:00
2025-10-02 23:20:59 +02:00
< div className = "border-t pt-4" >
< h4 className = "font-semibold mb-3" > SMART Attributes < / h4 >
< div className = "grid grid-cols-2 gap-4" >
< div >
< p className = "text-sm text-muted-foreground" > Temperature < / p >
2025-10-14 20:54:41 +02:00
< p
className = { ` font-medium ${ getTempColor ( selectedDisk . temperature , selectedDisk . name , selectedDisk . rotation_rate ) } ` }
>
2025-10-02 23:20:59 +02:00
{ selectedDisk . temperature > 0 ? ` ${ selectedDisk . temperature } °C ` : "N/A" }
< / p >
< / div >
< 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 >
2025-10-04 18:36:15 +02:00
< 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 >
2025-10-02 23:20:59 +02:00
< div >
< p className = "text-sm text-muted-foreground" > SMART Status < / p >
< p className = "font-medium capitalize" > { selectedDisk . smart_status } < / p >
< / div >
< div >
< p className = "text-sm text-muted-foreground" > Reallocated Sectors < / p >
< p
className = { ` font-medium ${ selectedDisk . reallocated_sectors && selectedDisk . reallocated_sectors > 0 ? "text-yellow-500" : "" } ` }
>
{ selectedDisk . reallocated_sectors ? ? 0 }
< / p >
< / div >
< div >
< p className = "text-sm text-muted-foreground" > Pending Sectors < / p >
< p
className = { ` font-medium ${ selectedDisk . pending_sectors && selectedDisk . pending_sectors > 0 ? "text-yellow-500" : "" } ` }
>
{ selectedDisk . pending_sectors ? ? 0 }
< / p >
< / div >
< div >
< p className = "text-sm text-muted-foreground" > CRC Errors < / p >
< p
className = { ` font-medium ${ selectedDisk . crc_errors && selectedDisk . crc_errors > 0 ? "text-yellow-500" : "" } ` }
>
{ selectedDisk . crc_errors ? ? 0 }
< / p >
< / div >
< / div >
< / div >
2026-03-05 17:29:07 +01:00
2026-04-13 19:11:57 +02:00
{ /* OLD SMART Test Data section removed — now unified in Wear & Lifetime above */ }
{ false && (
2026-04-13 14:49:48 +02:00
< 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" / >
2026-04-13 15:29:22 +02:00
{ ( ( ) = > {
// 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'
} ) ( ) }
2026-04-13 14:49:48 +02:00
{ 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" >
2026-04-13 15:29:22 +02:00
{ /* SSD/NVMe Life Estimation from JSON - Uniform style */ }
2026-04-13 14:49:48 +02:00
{ ( ( ) = > {
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 || [ ]
2026-04-13 15:29:22 +02:00
// Look for wear-related attributes for SSD
2026-04-13 14:49:48 +02:00
const wearAttr = table . find ( a = >
a . name ? . toLowerCase ( ) . includes ( 'wear_leveling' ) ||
a . name ? . toLowerCase ( ) . includes ( 'media_wearout' ) ||
a . name ? . toLowerCase ( ) . includes ( 'percent_lifetime' ) ||
2026-04-13 15:29:22 +02:00
a . name ? . toLowerCase ( ) . includes ( 'ssd_life_left' ) ||
2026-04-13 14:49:48 +02:00
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
)
2026-04-13 15:29:22 +02:00
// 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 `
}
2026-04-13 18:49:18 +02:00
// Get wear percentage (life remaining %)
2026-04-13 15:29:22 +02:00
let wearPercent : number | null = null
let wearLabel = 'Life Remaining'
if ( wearAttr ) {
2026-04-13 18:49:18 +02:00
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'
2026-04-13 15:29:22 +02:00
} 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 ) {
2026-04-13 14:49:48 +02:00
return (
2026-04-13 15:29:22 +02:00
< >
2026-04-13 18:49:18 +02:00
{ /* Wear Progress Bar - Blue style matching NVMe */ }
2026-04-13 15:29:22 +02:00
{ wearPercent !== null && (
< div >
< div className = "flex items-center justify-between mb-2" >
< p className = "text-sm text-muted-foreground" > { wearLabel } < / p >
2026-04-13 18:49:18 +02:00
< p className = "font-medium text-blue-400" >
2026-04-13 15:29:22 +02:00
{ wearPercent } %
2026-04-13 14:49:48 +02:00
< / p >
< / div >
2026-04-13 15:29:22 +02:00
< Progress
value = { wearPercent }
2026-04-13 18:49:18 +02:00
className = { ` h-2 ${ wearPercent < 20 ? '[&>div]:bg-red-500' : '[&>div]:bg-blue-500' } ` }
2026-04-13 15:29:22 +02:00
/ >
< / div >
) }
{ /* Stats Grid - Same layout as NVMe Wear & Lifetime */ }
< div className = "grid grid-cols-2 gap-4" >
{ estimatedLife && (
2026-04-13 14:49:48 +02:00
< div >
2026-04-13 15:29:22 +02:00
< p className = "text-sm text-muted-foreground" > Estimated Life Remaining < / p >
< p className = "font-medium" > { estimatedLife } < / p >
2026-04-13 14:49:48 +02:00
< / div >
) }
2026-04-13 15:29:22 +02:00
{ dataWrittenLabel && (
2026-04-13 14:49:48 +02:00
< div >
2026-04-13 15:29:22 +02:00
< p className = "text-sm text-muted-foreground" > Total Data Written < / p >
< p className = "font-medium" > { dataWrittenLabel } < / p >
2026-04-13 14:49:48 +02:00
< / 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 >
2026-04-13 15:29:22 +02:00
< / >
2026-04-13 14:49:48 +02:00
)
}
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 >
) }
2026-03-05 17:29:07 +01:00
{ /* Observations Section */ }
2026-03-08 22:47:04 +01:00
{ ( diskObservations . length > 0 || loadingObservations ) && (
2026-03-05 17:29:07 +01:00
< div className = "border-t pt-4" >
2026-03-06 12:06:53 +01:00
< h4 className = "font-semibold mb-2 flex items-center gap-2" >
2026-03-05 17:29:07 +01:00
< Info className = "h-4 w-4 text-blue-400" / >
Observations
< Badge className = "bg-blue-500/10 text-blue-400 border-blue-500/20 text-[10px] px-1.5 py-0" >
{ diskObservations . length }
< / Badge >
< / h4 >
2026-03-06 12:06:53 +01:00
< p className = "text-xs text-muted-foreground mb-3" >
The following observations have been recorded for this disk :
< / p >
2026-03-05 17:29:07 +01:00
{ 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 >
) : (
2026-03-06 18:44:27 +01:00
< div className = "space-y-3" >
2026-03-05 17:29:07 +01:00
{ diskObservations . map ( ( obs ) = > (
< div
key = { obs . id }
className = { ` rounded-lg border p-3 text-sm ${
obs . severity === 'critical'
? 'bg-red-500/5 border-red-500/20'
: 'bg-blue-500/5 border-blue-500/20'
} ` }
>
2026-03-06 12:06:53 +01:00
{ /* Header with type badge */ }
< div className = "flex items-center gap-2 flex-wrap mb-2" >
< Badge className = { ` text-[10px] px-1.5 py-0 ${
obs . severity === 'critical'
? 'bg-red-500/10 text-red-400 border-red-500/20'
: 'bg-blue-500/10 text-blue-400 border-blue-500/20'
} ` }>
{ obsTypeLabel ( obs . error_type ) }
< / Badge >
2026-03-05 17:29:07 +01:00
< / div >
2026-03-06 12:06:53 +01:00
{ /* Error message - responsive text wrap */ }
< p className = "text-xs whitespace-pre-wrap break-words opacity-90 font-mono leading-relaxed mb-3" >
2026-03-05 17:29:07 +01:00
{ obs . raw_message }
< / p >
2026-03-06 12:06:53 +01:00
{ /* 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" >
2026-03-05 17:29:07 +01:00
< span className = "flex items-center gap-1" >
2026-03-06 12:06:53 +01:00
< Clock className = "h-3 w-3 flex-shrink-0" / >
< span className = "break-words" > First : { formatObsDate ( obs . first_occurrence ) } < / span >
2026-03-05 17:29:07 +01:00
< / span >
2026-03-06 12:06:53 +01:00
< 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 >
2026-03-05 17:29:07 +01:00
< / div >
< / div >
) ) }
< / div >
) }
< / div >
) }
2025-10-02 23:20:59 +02:00
< / div >
) }
2026-04-12 20:32:34 +02:00
{ /* SMART Test Tab */ }
{ selectedDisk && activeModalTab === "smart" && (
2026-04-13 18:49:18 +02:00
< SmartTestTab disk = { selectedDisk } observations = { diskObservations } lastTestDate = { smartJsonData ? . timestamp || undefined } / >
2026-04-12 20:32:34 +02:00
) }
2026-04-13 14:49:48 +02:00
2026-04-16 11:43:42 +02:00
{ /* History Tab */ }
{ selectedDisk && activeModalTab === "history" && (
< HistoryTab disk = { selectedDisk } / >
) }
2026-04-13 14:49:48 +02:00
{ /* Schedule Tab */ }
{ selectedDisk && activeModalTab === "schedule" && (
< ScheduleTab disk = { selectedDisk } / >
) }
2026-04-12 20:32:34 +02:00
< / div >
< / DialogContent >
< / Dialog >
< / div >
)
}
2026-04-12 21:06:01 +02:00
// Generate SMART Report HTML and open in new window (same pattern as Lynis/Latency reports)
2026-04-16 16:42:11 +02:00
function openSmartReport ( disk : DiskInfo , testStatus : SmartTestStatus , smartAttributes : SmartAttribute [ ] , observations : DiskObservation [ ] = [ ] , lastTestDate? : string , targetWindow? : Window , isHistorical = false ) {
2026-04-12 21:06:01 +02:00
const now = new Date ( ) . toLocaleString ( )
const logoUrl = ` ${ window . location . origin } /images/proxmenux-logo.png `
const reportId = ` SMART- ${ Date . now ( ) . toString ( 36 ) . toUpperCase ( ) } `
2026-04-13 18:49:18 +02:00
// --- 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' )
2026-04-16 11:43:42 +02:00
// 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. `
}
}
2026-04-16 17:36:23 +02:00
// Determine disk type (SAS detected via backend flag or connection_type)
const isSasDisk = sd ? . is_sas === true || disk . connection_type === 'sas'
2026-04-12 21:06:01 +02:00
let diskType = "HDD"
if ( disk . name . startsWith ( "nvme" ) ) {
diskType = "NVMe"
2026-04-16 17:36:23 +02:00
} else if ( isSasDisk ) {
diskType = "SAS"
2026-04-12 21:06:01 +02:00
} 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 ( )
2026-04-13 18:49:18 +02:00
// Format power on time — force 'en' locale for consistent comma separator
const fmtNum = ( n : number ) = > n . toLocaleString ( 'en-US' )
2026-04-12 21:06:01 +02:00
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 )
2026-04-13 18:49:18 +02:00
const powerOnFormatted = powerOnYears > 0
? ` ${ powerOnYears } y ${ powerOnRemainingDays } d ( ${ fmtNum ( powerOnHours ) } h) `
: ` ${ powerOnDays } d ( ${ fmtNum ( powerOnHours ) } h) `
2026-04-12 21:06:01 +02:00
2026-04-12 23:45:23 +02:00
// Build attributes table - format differs for NVMe vs SATA
const isNvmeForTable = diskType === 'NVMe'
2026-04-13 15:29:22 +02:00
// 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.' ,
2026-04-16 17:36:23 +02:00
'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.' ,
2026-04-13 15:29:22 +02:00
'Power Cycles' : 'Total on/off cycles. Frequent cycling increases connector and capacitor wear.' ,
2026-04-16 17:36:23 +02:00
'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.' ,
2026-04-13 15:29:22 +02:00
}
2026-04-16 17:36:23 +02:00
// Explanations for SATA/SSD attributes — covers HDD, SSD, and mixed-use attributes
2026-04-13 15:29:22 +02:00
const sataExplanations : Record < string , string > = {
2026-04-16 17:36:23 +02:00
// === 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.' ,
2026-04-13 15:29:22 +02:00
'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.' ,
2026-04-16 17:36:23 +02:00
'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.' ,
2026-04-13 15:29:22 +02:00
'Pending Sectors' : 'Sectors waiting to be remapped. May resolve or become reallocated.' ,
2026-04-16 17:36:23 +02:00
'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.' ,
2026-04-13 15:29:22 +02:00
'CRC Errors' : 'Interface communication errors. Usually caused by cable or connection issues.' ,
2026-04-16 17:36:23 +02:00
'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.' ,
2026-04-13 15:29:22 +02:00
}
2026-04-16 17:36:23 +02:00
// 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 = > {
2026-04-13 15:29:22 +02:00
const cleanName = name . replace ( /_/g , ' ' )
2026-04-16 17:36:23 +02:00
if ( diskKind === 'NVMe' ) {
2026-04-13 15:29:22 +02:00
return nvmeExplanations [ cleanName ] || nvmeExplanations [ name ] || ''
}
2026-04-16 17:36:23 +02:00
if ( diskKind === 'SAS' ) {
return sasExplanations [ cleanName ] || sasExplanations [ name ] || ''
}
2026-04-13 15:29:22 +02:00
return sataExplanations [ cleanName ] || sataExplanations [ name ] || ''
}
2026-04-16 17:36:23 +02:00
// SAS and NVMe use simplified table format (Metric | Value | Status)
const useSimpleTable = isNvmeForTable || isSasDisk
2026-04-12 21:06:01 +02:00
const attributeRows = smartAttributes . map ( ( attr , i ) = > {
2026-04-12 21:28:36 +02:00
const statusColor = attr . status === 'ok' ? '#16a34a' : attr . status === 'warning' ? '#ca8a04' : '#dc2626'
const statusBg = attr . status === 'ok' ? '#16a34a15' : attr . status === 'warning' ? '#ca8a0415' : '#dc262615'
2026-04-16 17:36:23 +02:00
const explanation = getAttrExplanation ( attr . name , diskType )
2026-04-16 17:58:27 +02:00
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> `
: ''
2026-04-16 17:36:23 +02:00
if ( useSimpleTable ) {
2026-04-16 17:58:27 +02:00
// NVMe/SAS format: Metric | Value | Status
2026-04-16 17:36:23 +02:00
const displayValue = isSasDisk ? attr.raw_value : attr.value
2026-04-12 23:45:23 +02:00
return `
< tr >
2026-04-16 17:58:27 +02:00
< 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 >
2026-04-12 23:45:23 +02:00
< / tr >
2026-04-16 17:58:27 +02:00
$ { explainRow } `
2026-04-12 23:45:23 +02:00
} else {
2026-04-16 17:58:27 +02:00
// SATA format: ID | Attribute | Val | Worst | Thr | Raw | Status
2026-04-12 23:45:23 +02:00
return `
< tr >
2026-04-16 17:58:27 +02:00
< 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 >
2026-04-12 23:45:23 +02:00
< / tr >
2026-04-16 17:58:27 +02:00
$ { explainRow } `
2026-04-12 23:45:23 +02:00
}
2026-04-12 21:06:01 +02:00
} ) . join ( '' )
// Critical attributes to highlight
const criticalAttrs = smartAttributes . filter ( a = > a . status !== 'ok' )
const hasCritical = criticalAttrs . length > 0
2026-04-12 23:45:23 +02:00
// Temperature color based on disk type
const getTempColorForReport = ( temp : number ) : string = > {
if ( temp <= 0 ) return '#94a3b8' // gray for N/A
switch ( diskType ) {
case 'NVMe' :
// NVMe: <=70 green, 71-80 yellow, >80 red
if ( temp <= 70 ) return '#16a34a'
if ( temp <= 80 ) return '#ca8a04'
return '#dc2626'
case 'SSD' :
// SSD: <=59 green, 60-70 yellow, >70 red
if ( temp <= 59 ) return '#16a34a'
if ( temp <= 70 ) return '#ca8a04'
return '#dc2626'
2026-04-16 17:36:23 +02:00
case 'SAS' :
// SAS enterprise: <=55 green, 56-65 yellow, >65 red
if ( temp <= 55 ) return '#16a34a'
if ( temp <= 65 ) return '#ca8a04'
return '#dc2626'
2026-04-12 23:45:23 +02:00
case 'HDD' :
default :
// HDD: <=45 green, 46-55 yellow, >55 red
if ( temp <= 45 ) return '#16a34a'
if ( temp <= 55 ) return '#ca8a04'
return '#dc2626'
}
}
// Temperature thresholds for display
2026-04-16 17:36:23 +02:00
const tempThresholds = diskType === 'NVMe'
2026-04-12 23:45:23 +02:00
? { optimal : '<=70°C' , warning : '71-80°C' , critical : '>80°C' }
: diskType === 'SSD'
? { optimal : '<=59°C' , warning : '60-70°C' , critical : '>70°C' }
2026-04-16 17:36:23 +02:00
: diskType === 'SAS'
? { optimal : '<=55°C' , warning : '56-65°C' , critical : '>65°C' }
2026-04-12 23:45:23 +02:00
: { optimal : '<=45°C' , warning : '46-55°C' , critical : '>55°C' }
2026-04-16 17:36:23 +02:00
2026-04-12 23:45:23 +02:00
const isNvmeDisk = diskType === 'NVMe'
// NVMe Wear & Lifetime data
const nvmePercentUsed = testStatus . smart_data ? . nvme_raw ? . percent_used ? ? disk . percentage_used ? ? 0
const nvmeAvailSpare = testStatus . smart_data ? . nvme_raw ? . avail_spare ? ? 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
}
2026-04-12 21:06:01 +02:00
// Build recommendations
const recommendations : string [ ] = [ ]
if ( isHealthy ) {
recommendations . push ( '<div class="rec-item rec-ok"><div class="rec-icon">✓</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">✗</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">⚠</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">⚠</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">⚠</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">⚠</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">⚠</div><div><strong>High Temperature ( ${ disk . temperature } °C)</strong><p>NVMe is overheating. Consider adding a heatsink or improving case airflow.</p></div></div> ` )
}
2026-04-13 18:49:18 +02:00
// 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">✗</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">✗</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">⚠</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">ⓘ</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">ⓘ</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>' )
}
2026-04-12 21:06:01 +02:00
if ( recommendations . length === 1 && isHealthy ) {
2026-04-13 18:49:18 +02:00
recommendations . push ( '<div class="rec-item rec-info"><div class="rec-icon">ⓘ</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">ⓘ</div><div><strong>Backup Strategy</strong><p>Ensure critical data is backed up regularly regardless of disk health status.</p></div></div>' )
2026-04-12 21:06:01 +02:00
}
2026-04-12 23:54:52 +02:00
// 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 = > {
2026-04-13 09:18:09 +02:00
// 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'
2026-04-12 23:54:52 +02:00
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>' : ''
2026-04-13 09:18:09 +02:00
const errorTypeLabel = type === 'io_error' ? 'I/O Error' : type === 'smart_error' ? 'SMART Error' : type === 'filesystem_error' ? 'Filesystem Error' : type . replace ( /_/g , ' ' )
2026-04-12 23:54:52 +02:00
obsItemsHtml += `
2026-04-13 09:18:09 +02:00
< div style = "background:${infoBg};border:1px solid ${infoColor}30;border-radius:8px;padding:16px;" >
2026-04-12 23:54:52 +02:00
< div style = "display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px;" >
2026-04-13 09:18:09 +02:00
< 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 >
2026-04-13 14:49:48 +02:00
< 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 >
2026-04-12 23:54:52 +02:00
$ { dismissedBadge }
< / div >
< div style = "margin-bottom:10px;" >
2026-04-13 14:49:48 +02:00
< div style = "font-size:10px;color:#475569;margin-bottom:4px;" > Error Signature : < / div >
2026-04-12 23:54:52 +02:00
< 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;" >
2026-04-13 14:49:48 +02:00
< div style = "font-size:10px;color:#475569;margin-bottom:4px;" > Raw Message : < / div >
2026-04-12 23:54:52 +02:00
< 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 >
< / div >
2026-04-13 09:18:09 +02:00
< 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;" >
2026-04-12 23:54:52 +02:00
< div >
2026-04-13 14:49:48 +02:00
< span style = "color:#475569;" > Device : < / span >
2026-04-12 23:54:52 +02:00
< strong style = "color:#1e293b;margin-left:4px;" > $ { obs . device_name || disk . name } < / strong >
< / div >
< div >
2026-04-13 14:49:48 +02:00
< span style = "color:#475569;" > Serial : < / span >
2026-04-12 23:54:52 +02:00
< strong style = "color:#1e293b;margin-left:4px;" > $ { obs . serial || disk . serial || 'N/A' } < / strong >
< / div >
< div >
2026-04-13 14:49:48 +02:00
< span style = "color:#475569;" > Model : < / span >
2026-04-12 23:54:52 +02:00
< strong style = "color:#1e293b;margin-left:4px;" > $ { obs . model || disk . model || 'N/A' } < / strong >
< / div >
< div >
2026-04-13 14:49:48 +02:00
< span style = "color:#475569;" > First Seen : < / span >
2026-04-12 23:54:52 +02:00
< strong style = "color:#1e293b;margin-left:4px;" > $ { firstDate } < / strong >
< / div >
< div >
2026-04-13 14:49:48 +02:00
< span style = "color:#475569;" > Last Seen : < / span >
2026-04-12 23:54:52 +02:00
< 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 >
2026-04-13 14:49:48 +02:00
< span style = "background:#64748b15;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;" > $ { obsList . length } unique , $ { groupOccurrences } total < / span >
2026-04-12 23:54:52 +02:00
< / div >
< div style = "display:flex;flex-direction:column;gap:12px;" >
$ { obsItemsHtml }
< / div >
< / div >
`
} )
2026-04-13 08:38:32 +02:00
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 >
2026-04-13 14:49:48 +02:00
< 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 >
2026-04-12 23:54:52 +02:00
$ { groupsHtml }
< / div >
`
}
2026-04-12 21:06:01 +02:00
const html = ` <!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
2026-04-16 18:10:27 +02:00
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
2026-04-12 21:06:01 +02:00
< 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 : # 1 a1a2e ; background : # fff ; font - size : 13px ; line - height : 1.5 ; }
@page { margin : 10mm ; size : A4 ; }
2026-04-16 18:10:27 +02:00
/* === SCREEN: responsive layout === */
@media screen {
body { max - width : 1000px ; margin : 0 auto ; padding : 24px 32 px ; padding - top : 64px ; overflow - x : hidden ; }
}
@media screen and ( max - width : 640px ) {
body { padding : 16px ; padding - top : 64px ; }
. grid - 4 { grid - template - columns : 1fr 1 fr ; }
. grid - 3 { grid - template - columns : 1fr 1 fr ; }
. 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 === */
2026-04-12 21:06:01 +02:00
@media print {
2026-04-16 18:10:27 +02:00
html , body { margin : 0 ! important ; padding : 0 ! important ; width : 100 % ! important ; max - width : none ! important ; }
2026-04-12 21:06:01 +02:00
. no - print { display : none ! important ; }
2026-04-16 18:10:27 +02:00
. top - bar { display : none ! important ; }
2026-04-12 21:06:01 +02:00
. page - break { page - break - before : always ; }
* { - webkit - print - color - adjust : exact ! important ; print - color - adjust : exact ! important ; }
2026-04-16 18:10:27 +02:00
body { font - size : 11px ; padding - top : 0 ! important ; }
/* Force desktop grid layout regardless of viewport */
. grid - 4 { grid - template - columns : 1fr 1 fr 1 fr 1 fr ! important ; }
. grid - 3 { grid - template - columns : 1fr 1 fr 1 fr ! important ; }
. grid - 2 { grid - template - columns : 1fr 1 fr ! 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 ; }
2026-04-16 17:36:23 +02:00
. 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 : # 4 b5563 ; }
. exec - text p { color : # 374151 ; }
. card - label { color : # 4 b5563 ; }
. rpt - footer { color : # 4 b5563 ; }
[ style *= "color:#64748b" ] { color : # 374151 ! important ; }
[ style *= "color:#94a3b8" ] { color : # 4 b5563 ! important ; }
[ style *= "color: #64748b" ] { color : # 374151 ! important ; }
[ style *= "color: #94a3b8" ] { color : # 4 b5563 ! important ; }
[ style *= "color:#16a34a" ] , [ style *= "color: #16a34a" ] { color : # 16 a34a ! 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 ; }
2026-04-12 21:06:01 +02:00
}
2026-04-16 17:36:23 +02:00
/* Top bar for screen only */
2026-04-12 21:06:01 +02:00
. top - bar {
position : fixed ; top : 0 ; left : 0 ; right : 0 ; background : # 0 f172a ; color : # e2e8f0 ;
2026-04-16 17:36:23 +02:00
padding : 12px 16 px ; display : flex ; align - items : center ; justify - content : space - between ; z - index : 100 ;
font - size : 13px ;
2026-04-12 21:06:01 +02:00
}
. top - bar - left { display : flex ; align - items : center ; gap : 12px ; }
. top - bar - title { font - weight : 600 ; }
. top - bar - subtitle { font - size : 11px ; color : # 94 a3b8 ; }
. top - bar button {
background : # 06 b6d4 ; color : # fff ; border : none ; padding : 10px 20 px ; border - radius : 6px ;
font - size : 14px ; font - weight : 600 ; cursor : pointer ;
}
. top - bar button :hover { background : # 0891 b2 ; }
/* Header */
. rpt - header {
display : flex ; align - items : center ; justify - content : space - between ;
padding : 18px 0 ; border - bottom : 3px solid # 0 f172a ; 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 : # 0 f172a ; }
. rpt - header - left p { font - size : 11px ; color : # 64748 b ; }
. rpt - header - right { text - align : right ; font - size : 11px ; color : # 64748 b ; line - height : 1.6 ; }
. rpt - header - right . rid { font - family : monospace ; font - size : 10px ; color : # 94 a3b8 ; }
/* Sections */
. section { margin - bottom : 22px ; }
. section - title {
font - size : 14px ; font - weight : 700 ; color : # 0 f172a ; 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 : # 64748 b ; line - height : 1.5 ; }
/* Grids */
. grid - 2 { display : grid ; grid - template - columns : 1fr 1 fr ; gap : 8px ; margin - bottom : 8px ; }
. grid - 3 { display : grid ; grid - template - columns : 1fr 1 fr 1 fr ; gap : 8px ; margin - bottom : 8px ; }
. grid - 4 { display : grid ; grid - template - columns : 1fr 1 fr 1 fr 1 fr ; gap : 8px ; margin - bottom : 8px ; }
. card { padding : 10px 12 px ; background : # f8fafc ; border : 1px solid # e2e8f0 ; border - radius : 6px ; }
. card - label { font - size : 10px ; font - weight : 600 ; color : # 94 a3b8 ; text - transform : uppercase ; letter - spacing : 0.05em ; margin - bottom : 2px ; }
. card - value { font - size : 13px ; font - weight : 600 ; color : # 0 f172a ; }
. card - c { text - align : center ; }
. card - c . card - value { font - size : 20px ; font - weight : 800 ; }
/* Tags */
. f - tag { font - size : 9px ; padding : 2px 6 px ; border - radius : 4px ; font - weight : 600 ; }
/* Tables */
. attr - tbl { width : 100 % ; border - collapse : collapse ; font - size : 11px ; }
2026-04-12 21:28:36 +02:00
. attr - tbl th { text - align : left ; padding : 6px 4 px ; font - size : 10px ; color : # 64748 b ; font - weight : 600 ; border - bottom : 2px solid # e2e8f0 ; background : # f1f5f9 ; }
. attr - tbl td { padding : 5px 4 px ; border - bottom : 1px solid # f1f5f9 ; color : # 1 e293b ; }
2026-04-12 21:06:01 +02:00
. attr - tbl tr :hover { background : # f8fafc ; }
2026-04-12 21:28:36 +02:00
. attr - tbl . col - name { word - break : break - word ; }
. attr - tbl . col - raw { font - family : monospace ; font - size : 10px ; }
2026-04-12 21:06:01 +02:00
2026-04-16 17:58:27 +02:00
/* Attribute explanation rows: full-width below the data row */
. attr - explain - row td { padding - top : 0 ! important ; }
. attr - explain - row :hover { background : transparent ; }
2026-04-12 21:06:01 +02:00
/* 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 : # 64748 b ; margin : 0 ; }
. rec - ok { background : # dcfce7 ; border : 1px solid # 86 efac ; }
. rec - ok . rec - icon { color : # 16 a34a ; }
. 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 # 7 dd3fc ; }
. rec - info . rec - icon { color : # 0284 c7 ; }
/* Footer */
. rpt - footer {
margin - top : 32px ; padding - top : 12px ; border - top : 1px solid # e2e8f0 ;
display : flex ; justify - content : space - between ; font - size : 10px ; color : # 94 a3b8 ;
}
2026-04-16 17:36:23 +02:00
/ * N O T E : N o m o b i l e - s p e c i f i c l a y o u t o v e r r i d e s — p r i n t l a y o u t i s a l w a y s A 4 / d e s k t o p
regardless of the device generating the PDF . The @media print block above
handles all necessary print adjustments . * /
2026-04-12 21:06:01 +02:00
< / style >
< / head >
< body >
2026-04-16 17:36:23 +02:00
< 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 >
2026-04-12 21:06:01 +02:00
<!-- Top bar (screen only) -->
< div class = "top-bar no-print" >
2026-04-16 17:36:23 +02:00
< 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 >
2026-04-12 21:06:01 +02:00
< / div >
2026-04-16 17:36:23 +02:00
< button onclick = "pmxPrint()" > Print / Save as PDF < / button >
2026-04-12 21:06:01 +02:00
< / 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" >
2026-04-13 08:38:32 +02:00
< 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 ? '✓' : '✗' } < / div >
< div class = "health-lbl" > $ { healthLabel } < / div >
< / div >
2026-04-13 14:49:48 +02:00
< div style = "font-size:10px;color:#475569;font-weight:600;" > SMART Status < / div >
2026-04-12 21:06:01 +02:00
< / 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 >
2026-04-13 14:49:48 +02:00
<!-- 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;" >
2026-04-16 16:42:11 +02:00
< div style = "font-size:10px;color:#475569;font-weight:600;text-transform:uppercase;" > $ { isHistorical ? 'Test Type' : 'Last Test Type' } < / div >
2026-04-13 14:49:48 +02:00
< 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 >
2026-04-16 11:43:42 +02:00
$ { 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 >
` : ''}
2026-04-12 21:06:01 +02:00
< / 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 >
2026-04-13 18:49:18 +02:00
< div class = "card-value" style = "font-size:11px;" > $ { disk . model || sd ? . model || 'Unknown' } < / div >
2026-04-12 21:06:01 +02:00
< / div >
< div class = "card" >
< div class = "card-label" > Serial < / div >
2026-04-13 18:49:18 +02:00
< div class = "card-value" style = "font-size:11px;font-family:monospace;" > $ { disk . serial || sd ? . serial || 'Unknown' } < / div >
2026-04-12 21:06:01 +02:00
< / 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 >
2026-04-16 17:36:23 +02:00
< 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 >
2026-04-12 21:06:01 +02:00
< / div >
< / div >
2026-04-13 18:49:18 +02:00
$ { ( 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> ` : '' }
2026-04-16 17:36:23 +02:00
$ { 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> ` : '' }
2026-04-13 18:49:18 +02:00
< / div >
` : ''}
2026-04-12 21:06:01 +02:00
< div class = "grid-4" >
< div class = "card card-c" >
2026-04-12 23:45:23 +02:00
< div class = "card-value" style = "color:${getTempColorForReport(disk.temperature)}" > $ { disk . temperature > 0 ? disk . temperature + '°C' : 'N/A' } < / div >
2026-04-12 21:06:01 +02:00
< div class = "card-label" > Temperature < / div >
2026-04-13 14:49:48 +02:00
< div style = "font-size:9px;color:#475569;margin-top:2px;" > Optimal : $ { tempThresholds . optimal } < / div >
2026-04-12 21:06:01 +02:00
< / div >
< div class = "card card-c" >
2026-04-13 18:49:18 +02:00
< div class = "card-value" > $ { fmtNum ( powerOnHours ) } h < / div >
2026-04-12 21:06:01 +02:00
< div class = "card-label" > Power On Time < / div >
2026-04-13 18:49:18 +02:00
< div style = "font-size:9px;color:#475569;margin-top:2px;" > $ { powerOnYears } y $ { powerOnRemainingDays } d < / div >
2026-04-12 21:06:01 +02:00
< / div >
< div class = "card card-c" >
2026-04-13 18:49:18 +02:00
< div class = "card-value" > $ { fmtNum ( disk . power_cycles ? ? 0 ) } < / div >
2026-04-12 21:06:01 +02:00
< div class = "card-label" > Power Cycles < / div >
< / div >
< div class = "card card-c" >
2026-04-13 09:35:23 +02:00
< 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 >
2026-04-13 09:18:09 +02:00
< div class = "card-label" > SMART Status < / div >
2026-04-12 23:45:23 +02:00
< / div >
< / div >
2026-04-13 08:38:32 +02:00
$ { ! 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 >
2026-04-16 17:36:23 +02:00
< div class = "card-label" > $ { isSasDisk ? 'Uncorrected Errors' : 'Pending Sectors' } < / div >
2026-04-12 23:45:23 +02:00
< / div >
2026-04-13 08:38:32 +02:00
< div class = "card card-c" >
2026-04-16 17:36:23 +02:00
< div class = "card-value" style = "color:${isSasDisk ? '#94a3b8' : (disk.crc_errors ?? 0) > 0 ? '#ca8a04' : '#16a34a'}" > $ { isSasDisk ? 'N/A' : ( disk . crc_errors ? ? 0 ) } < / div >
2026-04-13 08:38:32 +02:00
< div class = "card-label" > CRC Errors < / div >
< / div >
< div class = "card card-c" >
2026-04-13 09:18:09 +02:00
< div class = "card-value" style = "color:${(disk.reallocated_sectors ?? 0) > 0 ? '#dc2626' : '#16a34a'}" > $ { disk . reallocated_sectors ? ? 0 } < / div >
2026-04-16 17:36:23 +02:00
< div class = "card-label" > $ { isSasDisk ? 'Grown Defects' : 'Reallocated Sectors' } < / div >
2026-04-12 23:45:23 +02:00
< / div >
< / div >
2026-04-13 08:38:32 +02:00
` : ''}
2026-04-12 23:45:23 +02:00
< / div >
2026-04-13 08:38:32 +02:00
2026-04-12 23:45:23 +02:00
$ { isNvmeDisk ? `
<!-- 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;" >
2026-04-13 14:49:48 +02:00
< div style = "font-size:12px;color:#475569;margin-bottom:8px;font-weight:600;" > LIFE REMAINING < / div >
2026-04-12 23:45:23 +02:00
< 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;" >
2026-04-13 14:49:48 +02:00
< div style = "font-size:12px;color:#475569;margin-bottom:12px;font-weight:600;" > USAGE STATISTICS < / div >
2026-04-12 23:45:23 +02:00
< div style = "margin-bottom:16px;" >
< div style = "display:flex;justify-content:space-between;margin-bottom:6px;" >
2026-04-13 14:49:48 +02:00
< span style = "font-size:12px;color:#475569;" > Percentage Used < / span >
2026-04-17 10:38:39 +02:00
< span style = "font-size:14px;font-weight:600;color:#3b82f6;" > $ { nvmePercentUsed } % < / span >
2026-04-12 23:45:23 +02:00
< / div >
< div style = "background:#e2e8f0;border-radius:4px;height:8px;overflow:hidden;" >
2026-04-17 10:38:39 +02:00
< div style = "background:#3b82f6;height:100%;width:${Math.min(nvmePercentUsed, 100)}%;border-radius:4px;" > < / div >
2026-04-12 23:45:23 +02:00
< / div >
< / div >
< div style = "margin-bottom:16px;" >
< div style = "display:flex;justify-content:space-between;margin-bottom:6px;" >
2026-04-13 14:49:48 +02:00
< span style = "font-size:12px;color:#475569;" > Available Spare < / span >
2026-04-12 23:45:23 +02:00
< 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 >
2026-04-13 14:49:48 +02:00
< div style = "font-size:11px;color:#475569;" > Data Written < / div >
2026-04-12 23:45:23 +02:00
< div style = "font-size:15px;font-weight:600;color:#1e293b;" > $ { nvmeDataWrittenTB >= 1 ? nvmeDataWrittenTB . toFixed ( 2 ) + ' TB' : ( nvmeDataWrittenTB * 1024 ) . toFixed ( 1 ) + ' GB' } < / div >
< / div >
< div >
2026-04-13 14:49:48 +02:00
< div style = "font-size:11px;color:#475569;" > Power Cycles < / div >
2026-04-13 18:49:18 +02:00
< 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 >
2026-04-12 23:45:23 +02:00
< / div >
< / div >
2026-04-12 21:06:01 +02:00
< / div >
< / div >
2026-04-13 18:49:18 +02:00
<!-- 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 > `
} ) ( ) }
2026-04-12 21:06:01 +02:00
< / div >
2026-04-12 23:45:23 +02:00
` : ''}
2026-04-12 21:06:01 +02:00
2026-04-13 14:49:48 +02:00
$ { ! 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
)
2026-04-13 18:49:18 +02:00
// 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
2026-04-13 14:49:48 +02:00
const lifeUsed = 100 - lifeRemaining
2026-04-13 18:49:18 +02:00
// Calculate data written — detect unit from attribute name
2026-04-13 14:49:48 +02:00
let dataWrittenTB = 0
if ( lbasWrittenAttr ? . raw_value ) {
const rawValue = parseInt ( lbasWrittenAttr . raw_value . replace ( /[^0-9]/g , '' ) )
if ( ! isNaN ( rawValue ) ) {
2026-04-13 18:49:18 +02:00
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 )
}
2026-04-13 14:49:48 +02:00
}
} else if ( disk . total_lbas_written ) {
2026-04-13 18:49:18 +02:00
dataWrittenTB = disk . total_lbas_written / 1024 // Already in GB from backend
2026-04-13 14:49:48 +02:00
}
return `
<!-- SSD Wear & Lifetime -->
2026-04-12 21:37:02 +02:00
< div class = "section" >
2026-04-13 14:49:48 +02:00
< 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 >
2026-04-17 10:38:39 +02:00
< span style = "font-size:14px;font-weight:600;color:#3b82f6;" > $ { lifeUsed } % < / span >
2026-04-13 14:49:48 +02:00
< / div >
< div style = "background:#e2e8f0;border-radius:4px;height:8px;overflow:hidden;" >
2026-04-17 10:38:39 +02:00
< div style = "background:#3b82f6;height:100%;width:${Math.min(lifeUsed, 100)}%;border-radius:4px;" > < / div >
2026-04-13 14:49:48 +02:00
< / 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 >
2026-04-13 18:49:18 +02:00
< div style = "font-size:15px;font-weight:600;color:#1e293b;" > $ { fmtNum ( powerOnHours ) } h < / div >
2026-04-13 14:49:48 +02:00
< / 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 ''
} ) ( ) : '' }
2026-04-16 17:36:23 +02:00
<!-- SMART Attributes / NVMe Health Metrics / SAS Error Counters -->
2026-04-13 14:49:48 +02:00
< div class = "section" >
2026-04-16 17:36:23 +02:00
< 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 >
2026-04-12 21:06:01 +02:00
< table class = "attr-tbl" >
< thead >
< tr >
2026-04-16 17:36:23 +02:00
$ { 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>' }
2026-04-12 21:28:36 +02:00
< th style = "width:36px;" > < / th >
2026-04-12 21:06:01 +02:00
< / tr >
< / thead >
< tbody >
2026-04-16 17:36:23 +02:00
$ { 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>' }
2026-04-12 21:37:02 +02:00
< / tbody >
2026-04-12 21:06:01 +02:00
< / table >
2026-04-12 21:37:02 +02:00
< / div >
2026-04-12 21:28:36 +02:00
2026-04-12 23:45:23 +02:00
<!-- 5. Last Test Result -->
2026-04-12 21:06:01 +02:00
< div class = "section" >
2026-04-16 16:42:11 +02:00
< div class = "section-title" > $ { isNvmeDisk ? '5' : '4' } . $ { isHistorical ? 'Self-Test Result' : 'Last Self-Test Result' } < / div >
2026-04-12 21:06:01 +02:00
$ { 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 >
2026-04-13 18:49:18 +02:00
< 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 >
2026-04-12 21:06:01 +02:00
< / div >
2026-04-13 18:49:18 +02:00
$ { ( 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 > ` : ''}
2026-04-16 16:42:11 +02:00
` : 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 >
2026-04-12 21:06:01 +02:00
` : `
2026-04-13 14:49:48 +02:00
< div style = "text-align:center;padding:20px;color:#64748b;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;" >
2026-04-12 21:06:01 +02:00
No self - test history available . Run a SMART self - test to see results here .
< / div >
` }
< / div >
2026-04-12 23:54:52 +02:00
$ { observationsHtml }
2026-04-12 21:06:01 +02:00
2026-04-13 08:38:32 +02:00
<!-- Recommendations -->
2026-04-12 23:45:23 +02:00
< div class = "section" >
2026-04-13 08:38:32 +02:00
< div class = "section-title" > $ { observations . length > 0 ? ( isNvmeDisk ? '7' : '6' ) : ( isNvmeDisk ? '6' : '5' ) } . Recommendations < / div >
2026-04-12 23:45:23 +02:00
$ { recommendations . join ( '' ) }
< / div >
<!-- Footer -->
2026-04-12 21:06:01 +02:00
< div class = "rpt-footer" >
< div > Report generated by ProxMenux Monitor < / div >
< div > ProxMenux Monitor v1 . 0.2 - beta < / div >
< / div >
< / body >
< / html > `
2026-04-16 16:08:58 +02:00
const blob = new Blob ( [ html ] , { type : "text/html" } )
const url = URL . createObjectURL ( blob )
2026-04-16 15:52:26 +02:00
if ( targetWindow && ! targetWindow . closed ) {
2026-04-16 16:08:58 +02:00
// Navigate the already-open window to the blob URL (proper navigation with back/close in webapp)
targetWindow . location . href = url
2026-04-16 15:52:26 +02:00
} else {
window . open ( url , "_blank" )
}
2026-04-12 21:06:01 +02:00
}
2026-04-12 20:32:34 +02:00
// SMART Test Tab Component
interface SmartTestTabProps {
disk : DiskInfo
2026-04-12 23:45:23 +02:00
observations? : DiskObservation [ ]
2026-04-13 18:49:18 +02:00
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 ) [ ]
2026-04-12 20:32:34 +02:00
}
interface SmartTestStatus {
status : 'idle' | 'running' | 'completed' | 'failed'
test_type? : string
progress? : number
result? : string
2026-04-13 18:49:18 +02:00
supports_progress_reporting? : boolean
supports_self_test? : boolean
2026-04-12 20:32:34 +02:00
last_test ? : {
type : string
status : string
timestamp : string
duration? : string
2026-04-12 22:35:12 +02:00
lifetime_hours? : number
2026-04-12 20:32:34 +02:00
}
smart_data ? : {
device : string
model : string
2026-04-13 18:49:18 +02:00
model_family? : string
2026-04-12 20:32:34 +02:00
serial : string
firmware : string
2026-04-13 18:49:18 +02:00
nvme_version? : string
2026-04-12 20:32:34 +02:00
smart_status : string
temperature : number
2026-04-13 18:49:18 +02:00
temperature_sensors ? : ( number | null ) [ ]
2026-04-12 20:32:34 +02:00
power_on_hours : number
2026-04-13 18:49:18 +02:00
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
2026-04-16 17:36:23 +02:00
is_sas? : boolean
logical_block_size? : number
2026-04-12 20:32:34 +02:00
}
2026-04-12 22:50:30 +02:00
tools_installed ? : {
smartctl : boolean
nvme : boolean
}
2026-04-12 20:32:34 +02:00
}
2026-04-13 18:49:18 +02:00
function SmartTestTab ( { disk , observations = [ ] , lastTestDate } : SmartTestTabProps ) {
2026-04-12 20:32:34 +02:00
const [ testStatus , setTestStatus ] = useState < SmartTestStatus > ( { status : 'idle' } )
const [ loading , setLoading ] = useState ( true )
const [ runningTest , setRunningTest ] = useState < 'short' | 'long' | null > ( null )
2026-04-12 21:06:01 +02:00
// Extract SMART attributes from testStatus for the report
const smartAttributes = testStatus . smart_data ? . attributes || [ ]
2026-04-12 20:32:34 +02:00
2026-04-13 10:07:09 +02:00
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
2026-04-12 20:32:34 +02:00
useEffect ( ( ) = > {
2026-04-13 10:07:09 +02:00
let pollInterval : NodeJS.Timeout | null = null
2026-04-12 20:32:34 +02:00
2026-04-13 10:07:09 +02:00
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
2026-04-12 20:32:34 +02:00
}
}
2026-04-13 10:07:09 +02:00
} , 5000 )
}
}
checkAndPoll ( )
return ( ) = > {
if ( pollInterval ) clearInterval ( pollInterval )
}
} , [ disk . name ] )
2026-04-12 20:32:34 +02:00
2026-04-12 22:35:12 +02:00
const [ testError , setTestError ] = useState < string | null > ( null )
2026-04-12 22:50:30 +02:00
const [ installing , setInstalling ] = useState ( false )
// Check if required tools are installed for this disk type
2026-04-12 23:11:31 +02:00
const isNvme = disk . name . includes ( 'nvme' )
2026-04-12 22:50:30 +02:00
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 )
2026-04-12 22:58:49 +02:00
const data = await fetchApi < { success : boolean ; error? : string } > ( '/api/storage/smart/tools/install' , {
2026-04-12 22:50:30 +02:00
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' )
}
2026-04-12 22:58:49 +02:00
} catch ( err ) {
const message = err instanceof Error ? err . message : 'Failed to install tools'
setTestError ( ` ${ message } . Try manually: apt-get install smartmontools nvme-cli ` )
2026-04-12 22:50:30 +02:00
} finally {
setInstalling ( false )
}
}
2026-04-12 22:35:12 +02:00
2026-04-12 20:32:34 +02:00
const runSmartTest = async ( testType : 'short' | 'long' ) = > {
try {
setRunningTest ( testType )
2026-04-12 22:35:12 +02:00
setTestError ( null )
2026-04-13 10:07:09 +02:00
2026-04-12 22:58:49 +02:00
await fetchApi ( ` /api/storage/smart/ ${ disk . name } /test ` , {
2026-04-12 20:32:34 +02:00
method : 'POST' ,
body : JSON.stringify ( { test_type : testType } )
} )
2026-04-12 22:35:12 +02:00
2026-04-13 10:07:09 +02:00
// Immediately fetch status to show progress bar
fetchSmartStatus ( )
2026-04-12 20:32:34 +02:00
// Poll for status updates
2026-04-13 15:29:22 +02:00
// 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)
2026-04-12 20:32:34 +02:00
const pollInterval = setInterval ( async ( ) = > {
2026-04-13 15:29:22 +02:00
pollCount ++
2026-04-12 20:32:34 +02:00
try {
2026-04-13 15:29:22 +02:00
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' ) {
2026-04-12 20:32:34 +02:00
clearInterval ( pollInterval )
setRunningTest ( null )
2026-04-13 15:29:22 +02:00
// Refresh SMART JSON data to get new test results
fetchSmartStatus ( )
2026-04-12 20:32:34 +02:00
}
} catch {
2026-04-13 15:29:22 +02:00
// Don't clear on error - keep showing progress
}
// Safety timeout: stop polling after max duration
if ( pollCount >= maxPolls ) {
2026-04-12 20:32:34 +02:00
clearInterval ( pollInterval )
setRunningTest ( null )
2026-04-13 15:29:22 +02:00
// Refresh status one more time to get final result
fetchSmartStatus ( )
2026-04-12 20:32:34 +02:00
}
} , 5000 )
2026-04-13 15:29:22 +02:00
2026-04-12 22:35:12 +02:00
} catch ( err ) {
2026-04-12 22:58:49 +02:00
const message = err instanceof Error ? err . message : 'Failed to start test'
setTestError ( message )
2026-04-12 20:32:34 +02:00
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 >
)
}
2026-04-12 22:50:30 +02:00
// 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 >
)
}
2026-04-12 20:32:34 +02:00
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 >
2026-04-13 15:29:22 +02:00
2026-04-12 20:32:34 +02:00
< div className = "flex flex-wrap gap-3" >
2026-04-13 15:29:22 +02:00
< 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 >
2026-04-12 20:32:34 +02:00
< / 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 >
2026-04-12 22:35:12 +02:00
2026-04-13 15:29:22 +02:00
{ /* 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 >
2026-04-12 20:32:34 +02:00
2026-04-13 15:29:22 +02:00
{ /* Test Progress - Show when API reports running OR when we just started a test */ }
{ ( testStatus . status === 'running' || runningTest !== null ) && (
2026-04-12 20:32:34 +02:00
< div className = "border rounded-lg p-4 bg-blue-500/5 border-blue-500/20" >
2026-04-13 15:29:22 +02:00
< div className = "flex items-center gap-3" >
2026-04-12 20:32:34 +02:00
< Loader2 className = "h-5 w-5 animate-spin text-blue-500" / >
2026-04-13 15:29:22 +02:00
< div className = "flex-1" >
2026-04-12 20:32:34 +02:00
< p className = "font-medium text-blue-500" >
2026-04-13 15:29:22 +02:00
{ ( runningTest || testStatus . test_type ) === 'short' ? 'Short' : 'Extended' } test in progress
2026-04-12 20:32:34 +02:00
< / p >
< p className = "text-xs text-muted-foreground" >
2026-04-13 18:49:18 +02:00
Please wait while the test completes . Buttons will unlock when it finishes .
2026-04-12 20:32:34 +02:00
< / p >
< / div >
< / div >
2026-04-13 18:49:18 +02:00
{ /* Progress bar if disk reports percentage */ }
{ testStatus . progress !== undefined ? (
2026-04-13 15:29:22 +02:00
< Progress value = { testStatus . progress } className = "h-2 mt-3 [&>div]:bg-blue-500" / >
2026-04-13 18:49:18 +02:00
) : (
< >
< 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 >
< / >
2026-04-12 20:32:34 +02:00
) }
< / div >
) }
2026-04-16 17:58:27 +02:00
{ / * L a s t T e s t R e s u l t — o n l y s h o w i f a t e s t w a s e x e c u t e d f r o m P r o x M e n u x ( l a s t T e s t D a t e e x i s t s )
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 && (
2026-04-16 11:43:42 +02:00
< 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 >
2026-04-16 17:58:27 +02:00
< span className = "text-xs text-muted-foreground" >
{ new Date ( lastTestDate ) . toLocaleString ( ) }
< / span >
2026-04-12 20:32:34 +02:00
< / 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" / >
2026-04-16 17:36:23 +02:00
{ isNvme ? 'NVMe Health Metrics' : testStatus . smart_data ? . is_sas ? 'SAS/SCSI Health Metrics' : 'SMART Attributes' }
2026-04-12 20:32:34 +02:00
< / h4 >
< div className = "border rounded-lg overflow-hidden" >
2026-04-16 17:36:23 +02:00
< 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 > }
2026-04-12 20:32:34 +02:00
< 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 ) = > (
2026-04-16 17:36:23 +02:00
< 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 > }
2026-04-12 20:32:34 +02:00
< 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"
2026-04-12 21:06:01 +02:00
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"
2026-04-16 11:43:42 +02:00
onClick = { ( ) = > openSmartReport ( disk , testStatus , smartAttributes , observations , lastTestDate ) }
2026-04-12 20:32:34 +02:00
>
< 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 >
2026-04-12 21:06:01 +02:00
2025-10-02 22:29:24 +02:00
< / div >
)
}
2026-04-13 14:49:48 +02:00
2026-04-16 11:43:42 +02:00
// ─── 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 )
2026-04-16 12:08:36 +02:00
const [ viewingReport , setViewingReport ] = useState < string | null > ( null )
2026-04-16 11:43:42 +02:00
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 {
2026-04-16 12:08:36 +02:00
// Silently fail
2026-04-16 11:43:42 +02:00
} finally {
setDeleting ( null )
}
}
2026-04-16 12:08:36 +02:00
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
}
2026-04-16 11:43:42 +02:00
}
const handleViewReport = async ( entry : SmartHistoryEntry ) = > {
2026-04-16 15:28:48 +02:00
// 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>' )
}
2026-04-16 11:43:42 +02:00
try {
2026-04-16 12:08:36 +02:00
setViewingReport ( entry . filename )
2026-04-16 16:42:11 +02:00
// 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 || [ ]
2026-04-16 12:08:36 +02:00
2026-04-16 16:42:11 +02:00
openSmartReport ( disk , fullStatus , attrs , [ ] , entry . timestamp , reportWindow || undefined , true )
2026-04-16 11:43:42 +02:00
} catch {
2026-04-16 15:28:48 +02:00
if ( reportWindow && ! reportWindow . closed ) {
reportWindow . document . body . innerHTML = '<p style="color:#ef4444;text-align:center;margin-top:40vh">Failed to load report data.</p>'
}
2026-04-16 12:08:36 +02:00
} finally {
setViewingReport ( null )
2026-04-16 11:43:42 +02:00
}
}
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 (
2026-04-17 10:38:39 +02:00
< 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 >
2026-04-16 11:43:42 +02:00
< / 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
2026-04-16 12:08:36 +02:00
const isViewing = viewingReport === entry . filename
2026-04-16 11:43:42 +02:00
return (
< div
key = { entry . filename }
2026-04-16 15:09:16 +02:00
onClick = { ( ) = > ! isDeleting && handleViewReport ( entry ) }
className = { ` border rounded-lg p-3 flex items-center gap-3 transition-colors cursor-pointer hover:bg-white/5 ${
2026-04-16 12:08:36 +02:00
isLatest ? 'border-orange-500/30' : 'border-border'
2026-04-16 15:09:16 +02:00
} $ { isDeleting ? 'opacity-50 pointer-events-none' : '' } $ { isViewing ? 'opacity-70' : '' } ` }
2026-04-16 11:43:42 +02:00
>
2026-04-16 15:09:16 +02:00
{ 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 >
) }
2026-04-16 11:43:42 +02:00
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium truncate" >
{ testDate . toLocaleString ( ) }
2026-04-16 12:08:36 +02:00
{ isLatest && < span className = "text-[10px] text-orange-400 ml-2" > latest < / span > }
2026-04-16 11:43:42 +02:00
< / 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" >
2026-04-16 12:08:36 +02:00
< Button
variant = "ghost" size = "sm"
2026-04-16 11:43:42 +02:00
className = "h-7 w-7 p-0 text-muted-foreground hover:text-blue-400"
2026-04-16 15:09:16 +02:00
onClick = { ( e : unknown ) = > { ( e as MouseEvent ) . stopPropagation ( ) ; handleDownload ( entry . filename ) } }
2026-04-16 11:43:42 +02:00
title = "Download JSON"
>
< Download className = "h-3.5 w-3.5" / >
< / Button >
< Button
2026-04-16 12:08:36 +02:00
variant = "ghost" size = "sm"
2026-04-16 11:43:42 +02:00
className = "h-7 w-7 p-0 text-muted-foreground hover:text-red-400"
2026-04-16 15:09:16 +02:00
onClick = { ( e : unknown ) = > { ( e as MouseEvent ) . stopPropagation ( ) ; if ( confirm ( 'Delete this test record?' ) ) handleDelete ( entry . filename ) } }
2026-04-16 11:43:42 +02:00
disabled = { isDeleting }
title = "Delete"
>
2026-04-16 12:08:36 +02:00
{ isDeleting ? < Loader2 className = "h-3.5 w-3.5 animate-spin" / > : < Trash2 className = "h-3.5 w-3.5" / > }
2026-04-16 11:43:42 +02:00
< / 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 >
)
}
2026-04-13 14:49:48 +02:00
// ─── 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 ,
2026-04-16 16:57:40 +02:00
disks : [ disk . name ] ,
2026-04-13 14:49:48 +02:00
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 ,
2026-04-16 16:57:40 +02:00
disks : [ disk . name ] ,
2026-04-13 14:49:48 +02:00
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 }
2026-04-16 16:42:11 +02:00
className = "bg-purple-600 hover:bg-purple-700 text-white"
2026-04-13 14:49:48 +02:00
>
{ 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 >
)
}