import { t } from "@lingui/core/macro" import { type ColumnDef, type ColumnFiltersState, type Column, type SortingState, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table" import { Activity, Box, Clock, HardDrive, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon, MoreHorizontalIcon, RefreshCwIcon, ServerIcon, Trash2Icon, XIcon, } from "lucide-react" import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Input } from "@/components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { pb } from "@/lib/api" import type { SmartDeviceRecord, SmartAttribute } from "@/types" import { formatBytes, toFixedFloat, formatTemperature, cn, secondsToString, hourWithSeconds, formatShortDate, } from "@/lib/utils" import { Trans } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { $allSystemsById } from "@/lib/stores" import { ThermometerIcon } from "@/components/ui/icons" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Separator } from "@/components/ui/separator" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { useCallback, useMemo, useEffect, useState } from "react" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" // Column definition for S.M.A.R.T. attributes table export const smartColumns: ColumnDef[] = [ { accessorKey: "id", header: "ID", }, { accessorFn: (row) => row.n, header: "Name", }, { accessorFn: (row) => row.rs || row.rv?.toString(), header: "Value", }, { accessorKey: "v", header: "Normalized", }, { accessorKey: "w", header: "Worst", }, { accessorKey: "t", header: "Threshold", }, { // accessorFn: (row) => row.wf, accessorKey: "wf", header: "Failing", }, ] export type DiskInfo = { id: string system: string device: string model: string capacity: string status: string temperature: number deviceType: string powerOnHours?: number powerCycles?: number attributes?: SmartAttribute[] updated: string } // Function to format capacity display function formatCapacity(bytes: number): string { const { value, unit } = formatBytes(bytes) return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}` } // Function to convert SmartDeviceRecord to DiskInfo function convertSmartDeviceRecordToDiskInfo(records: SmartDeviceRecord[]): DiskInfo[] { const unknown = "Unknown" return records.map((record) => ({ id: record.id, system: record.system, device: record.name || unknown, model: record.model || unknown, serialNumber: record.serial || unknown, firmwareVersion: record.firmware || unknown, capacity: record.capacity ? formatCapacity(record.capacity) : unknown, status: record.state || unknown, temperature: record.temp || 0, deviceType: record.type || unknown, attributes: record.attributes, updated: record.updated, powerOnHours: record.hours, powerCycles: record.cycles, })) } const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated" export const columns: ColumnDef[] = [ { id: "system", accessorFn: (record) => record.system, sortingFn: (a, b) => { const allSystems = $allSystemsById.get() const systemNameA = allSystems[a.original.system]?.name ?? "" const systemNameB = allSystems[b.original.system]?.name ?? "" return systemNameA.localeCompare(systemNameB) }, header: ({ column }) => , cell: ({ getValue }) => { const allSystems = useStore($allSystemsById) return {allSystems[getValue() as string]?.name ?? ""} }, }, { accessorKey: "device", sortingFn: (a, b) => a.original.device.localeCompare(b.original.device), header: ({ column }) => , cell: ({ row }) => (
{row.getValue("device")}
), }, { accessorKey: "model", sortingFn: (a, b) => a.original.model.localeCompare(b.original.model), header: ({ column }) => , cell: ({ row }) => (
{row.getValue("model")}
), }, { accessorKey: "capacity", header: ({ column }) => , cell: ({ getValue }) => {getValue() as string}, }, { accessorKey: "status", header: ({ column }) => , cell: ({ getValue }) => { const status = getValue() as string return (
{status}
) }, }, { accessorKey: "deviceType", sortingFn: (a, b) => a.original.deviceType.localeCompare(b.original.deviceType), header: ({ column }) => , cell: ({ getValue }) => (
{getValue() as string}
), }, { accessorKey: "powerOnHours", invertSorting: true, header: ({ column }) => ( ), cell: ({ getValue }) => { const hours = (getValue() ?? 0) as number if (!hours && hours !== 0) { return
N/A
} const seconds = hours * 3600 return (
{secondsToString(seconds, "hour")}
{secondsToString(seconds, "day")}
) }, }, { accessorKey: "powerCycles", invertSorting: true, header: ({ column }) => ( ), cell: ({ getValue }) => { const cycles = getValue() as number | undefined if (!cycles && cycles !== 0) { return
N/A
} return {cycles} }, }, { accessorKey: "temperature", invertSorting: true, header: ({ column }) => , cell: ({ getValue }) => { const { value, unit } = formatTemperature(getValue() as number) return {`${value} ${unit}`} }, }, // { // accessorKey: "serialNumber", // sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber), // header: ({ column }) => , // cell: ({ getValue }) => {getValue() as string}, // }, // { // accessorKey: "firmwareVersion", // sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion), // header: ({ column }) => , // cell: ({ getValue }) => {getValue() as string}, // }, { id: "updated", invertSorting: true, accessorFn: (record) => record.updated, header: ({ column }) => , cell: ({ getValue }) => { const timestamp = getValue() as string // if today, use hourWithSeconds, otherwise use formatShortDate const formatter = new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate return {formatter(timestamp)} }, }, ] function HeaderButton({ column, name, Icon }: { column: Column; name: string; Icon: React.ElementType }) { const isSorted = column.getIsSorted() return ( ) } export default function DisksTable({ systemId }: { systemId?: string }) { const [sorting, setSorting] = useState([{ id: systemId ? "device" : "system", desc: false }]) const [columnFilters, setColumnFilters] = useState([]) const [rowSelection, setRowSelection] = useState({}) const [smartDevices, setSmartDevices] = useState(undefined) const [activeDiskId, setActiveDiskId] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null) const [globalFilter, setGlobalFilter] = useState("") const openSheet = (disk: DiskInfo) => { setActiveDiskId(disk.id) setSheetOpen(true) } // Fetch smart devices from collection (without attributes to save bandwidth) const fetchSmartDevices = useCallback(() => { pb.collection("smart_devices") .getFullList({ filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined, fields: SMART_DEVICE_FIELDS, }) .then((records) => { setSmartDevices(records) }) .catch(() => setSmartDevices([])) }, [systemId]) // Fetch smart devices when component mounts or systemId changes useEffect(() => { fetchSmartDevices() }, [fetchSmartDevices]) // Subscribe to live updates so rows add/remove without manual refresh/filtering useEffect(() => { let unsubscribe: (() => void) | undefined const pbOptions = systemId ? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) } : { fields: SMART_DEVICE_FIELDS } ; (async () => { try { unsubscribe = await pb.collection("smart_devices").subscribe( "*", (event) => { const record = event.record as SmartDeviceRecord setSmartDevices((currentDevices) => { const devices = currentDevices ?? [] const matchesSystemScope = !systemId || record.system === systemId if (event.action === "delete") { return devices.filter((device) => device.id !== record.id) } if (!matchesSystemScope) { // Record moved out of scope; ensure it disappears locally. return devices.filter((device) => device.id !== record.id) } const existingIndex = devices.findIndex((device) => device.id === record.id) if (existingIndex === -1) { return [record, ...devices] } const next = [...devices] next[existingIndex] = record return next }) }, pbOptions ) } catch (error) { console.error("Failed to subscribe to SMART device updates:", error) } })() return () => { unsubscribe?.() } }, [systemId]) const handleRowRefresh = useCallback( async (disk: DiskInfo) => { if (!disk.system) return setRowActionState({ type: "refresh", id: disk.id }) try { await pb.send("/api/beszel/smart/refresh", { method: "POST", query: { system: disk.system }, }) } catch (error) { console.error("Failed to refresh SMART device:", error) } finally { setRowActionState((state) => (state?.id === disk.id ? null : state)) } }, [fetchSmartDevices] ) const handleDeleteDevice = useCallback(async (disk: DiskInfo) => { setRowActionState({ type: "delete", id: disk.id }) try { await pb.collection("smart_devices").delete(disk.id) // setSmartDevices((current) => current?.filter((device) => device.id !== disk.id)) } catch (error) { console.error("Failed to delete SMART device:", error) } finally { setRowActionState((state) => (state?.id === disk.id ? null : state)) } }, []) const actionColumn = useMemo>( () => ({ id: "actions", enableSorting: false, header: () => ( Actions ), cell: ({ row }) => { const disk = row.original const isRowRefreshing = rowActionState?.id === disk.id && rowActionState.type === "refresh" const isRowDeleting = rowActionState?.id === disk.id && rowActionState.type === "delete" return (
event.stopPropagation()}> { event.stopPropagation() handleRowRefresh(disk) }} disabled={isRowRefreshing || isRowDeleting} > Refresh { event.stopPropagation() handleDeleteDevice(disk) }} disabled={isRowDeleting} > Delete
) }, }), [handleRowRefresh, handleDeleteDevice, rowActionState] ) // Filter columns based on whether systemId is provided const tableColumns = useMemo(() => { const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns return [...baseColumns, actionColumn] }, [systemId, actionColumn]) // Convert SmartDeviceRecord to DiskInfo const diskData = useMemo(() => { return smartDevices ? convertSmartDeviceRecordToDiskInfo(smartDevices) : [] }, [smartDevices]) const table = useReactTable({ data: diskData, columns: tableColumns, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), onRowSelectionChange: setRowSelection, state: { sorting, columnFilters, rowSelection, globalFilter, }, onGlobalFilterChange: setGlobalFilter, globalFilterFn: (row, _columnId, filterValue) => { const disk = row.original const systemName = $allSystemsById.get()[disk.system]?.name ?? "" const device = disk.device ?? "" const model = disk.model ?? "" const status = disk.status ?? "" const type = disk.deviceType ?? "" const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase() return (filterValue as string) .toLowerCase() .split(" ") .every((term) => searchString.includes(term)) }, }) // Hide the table on system pages if there's no data, but always show on global page if (systemId && !diskData.length && !columnFilters.length) { return null } return (
S.M.A.R.T. Click on a device to view more information.
setGlobalFilter(event.target.value)} className="px-4 w-full max-w-full md:w-64" /> {globalFilter && ( )}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ) })} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( openSheet(row.original)} > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) ) : ( {smartDevices ? ( t`No results.` ) : ( )} )}
) } function DiskSheet({ diskId, open, onOpenChange, }: { diskId: string | null open: boolean onOpenChange: (open: boolean) => void }) { const [disk, setDisk] = useState(null) const [isLoading, setIsLoading] = useState(false) // Fetch full device record (including attributes) when sheet opens useEffect(() => { if (!diskId) { setDisk(null) return } // Only fetch when opening, not when closing (keeps data visible during close animation) if (!open) return setIsLoading(true) pb.collection("smart_devices") .getOne(diskId) .then(setDisk) .catch(() => setDisk(null)) .finally(() => setIsLoading(false)) }, [open, diskId]) const smartAttributes = disk?.attributes || [] // Find all attributes where when failed is not empty const failedAttributes = smartAttributes.filter((attr) => attr.wf && attr.wf.trim() !== "") // Filter columns to only show those that have values in at least one row const visibleColumns = useMemo(() => { return smartColumns.filter((column) => { const accessorKey = "accessorKey" in column ? (column.accessorKey as keyof SmartAttribute | undefined) : undefined if (!accessorKey) { return true } // Check if any row has a non-empty value for this column return smartAttributes.some((attr) => { return attr[accessorKey] !== undefined }) }) }, [smartAttributes]) const table = useReactTable({ data: smartAttributes, columns: visibleColumns, getCoreRowModel: getCoreRowModel(), }) const unknown = "Unknown" const deviceName = disk?.name || unknown const model = disk?.model || unknown const capacity = disk?.capacity ? formatCapacity(disk.capacity) : unknown const serialNumber = disk?.serial || unknown const firmwareVersion = disk?.firmware || unknown const status = disk?.state || unknown return ( S.M.A.R.T. Details - {deviceName} {model} {capacity} {serialNumber} Serial Number {firmwareVersion} Firmware
{isLoading ? (
) : ( <> {status === "PASSED" ? : } S.M.A.R.T. Self-Test: {status} {failedAttributes.length > 0 && ( Failed Attributes: {failedAttributes.map((attr) => attr.n).join(", ")} )} {smartAttributes.length > 0 ? (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ))} ))} {table.getRowModel().rows.map((row) => { // Check if the attribute is failed const isFailedAttribute = row.original.wf && row.original.wf.trim() !== "" return ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) })}
) : (
No S.M.A.R.T. attributes available for this device.
)} )}
) }