import { t } from "@lingui/core/macro" import { type ColumnDef, type ColumnFiltersState, type Column, type Row, type SortingState, type Table as TableType, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" 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, getVisualStringWidth, 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 { memo, useCallback, useMemo, useEffect, useRef, 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", }, ] // Function to format capacity display function formatCapacity(bytes: number): string { const { value, unit } = formatBytes(bytes) return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}` } const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated" export const createColumns = ( longestName: number, longestModel: number, longestDevice: number ): 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: "name", sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), header: ({ column }) => , cell: ({ getValue }) => (
{getValue() as string}
), }, { accessorKey: "model", sortingFn: (a, b) => a.original.model.localeCompare(b.original.model), header: ({ column }) => , cell: ({ getValue }) => (
{getValue() as string}
), }, { accessorKey: "capacity", invertSorting: true, header: ({ column }) => , cell: ({ getValue }) => {formatCapacity(getValue() as number)}, }, { accessorKey: "state", header: ({ column }) => , cell: ({ getValue }) => { const status = getValue() as string return ( {status} ) }, }, { accessorKey: "type", sortingFn: (a, b) => a.original.type.localeCompare(b.original.type), header: ({ column }) => , cell: ({ getValue }) => ( {getValue() as string} ), }, { accessorKey: "hours", invertSorting: true, header: ({ column }) => ( ), cell: ({ getValue }) => { const hours = getValue() as number | undefined if (hours == null) { return
N/A
} const seconds = hours * 3600 return (
{secondsToString(seconds, "hour")}
{secondsToString(seconds, "day")}
) }, }, { accessorKey: "cycles", invertSorting: true, header: ({ column }) => ( ), cell: ({ getValue }) => { const cycles = getValue() as number | undefined if (cycles == null) { return
N/A
} return {cycles.toLocaleString()} }, }, { accessorKey: "temp", invertSorting: true, header: ({ column }) => , cell: ({ getValue }) => { const temp = getValue() as number | null | undefined if (!temp) { return
N/A
} const { value, unit } = formatTemperature(temp) return {`${value} ${unit}`} }, }, // { // accessorKey: "serial", // sortingFn: (a, b) => a.original.serial.localeCompare(b.original.serial), // header: ({ column }) => , // cell: ({ getValue }) => {getValue() as string}, // }, // { // accessorKey: "firmware", // sortingFn: (a, b) => a.original.firmware.localeCompare(b.original.firmware), // 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 ? "name" : "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 allSystems = useStore($allSystemsById) // duplicate the devices to test with more rows // if ( // smartDevices?.length && // smartDevices.length < 50 && // typeof window !== "undefined" && // window.location.hostname === "localhost" // ) { // setSmartDevices([...smartDevices, ...smartDevices, ...smartDevices]) // } // Calculate the right width for the columns based on the longest strings among the displayed devices const { longestName, longestModel, longestDevice } = useMemo(() => { const result = { longestName: 0, longestModel: 0, longestDevice: 0 } if (!smartDevices || Object.keys(allSystems).length === 0) { return result } const seenSystems = new Set() for (const device of smartDevices) { if (!systemId && !seenSystems.has(device.system)) { seenSystems.add(device.system) const name = allSystems[device.system]?.name ?? "" result.longestName = Math.max(result.longestName, getVisualStringWidth(name)) } result.longestModel = Math.max(result.longestModel, getVisualStringWidth(device.model ?? "")) result.longestDevice = Math.max(result.longestDevice, getVisualStringWidth(device.name ?? "")) } return result }, [smartDevices, systemId, allSystems]) const openSheet = (disk: SmartDeviceRecord) => { setActiveDiskId(disk.id) setSheetOpen(true) } // Fetch smart devices useEffect(() => { const controller = new AbortController() pb.collection("smart_devices") .getFullList({ filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined, fields: SMART_DEVICE_FIELDS, signal: controller.signal, }) .then(setSmartDevices) .catch((err) => { if (!err.isAbort) { setSmartDevices([]) } }) return () => controller.abort() }, [systemId]) // Subscribe to updates 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: SmartDeviceRecord) => { 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)) } }, []) const handleDeleteDevice = useCallback(async (disk: SmartDeviceRecord) => { 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 columns = createColumns(longestName, longestModel, longestDevice) const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns return [...baseColumns, actionColumn] }, [systemId, actionColumn, longestName, longestModel, longestDevice]) const table = useReactTable({ data: smartDevices || ([] as SmartDeviceRecord[]), 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.name ?? "" const model = disk.model ?? "" const status = disk.state ?? "" const type = disk.type ?? "" const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase() return (filterValue as string) .toLowerCase() .split(" ") .every((term) => searchString.includes(term)) }, }) const rows = table.getRowModel().rows // Hide the table on system pages if there's no data, but always show on global page if (systemId && !smartDevices?.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 && ( )}
) } const SmartDevicesTable = memo(function SmartDevicesTable({ table, rows, colLength, data, openSheet, }: { table: TableType rows: Row[] colLength: number data: SmartDeviceRecord[] | undefined openSheet: (disk: SmartDeviceRecord) => void }) { const scrollRef = useRef(null) const virtualizer = useVirtualizer({ count: rows.length, estimateSize: () => 65, getScrollElement: () => scrollRef.current, overscan: 5, }) const virtualRows = virtualizer.getVirtualItems() const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin) const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)) return (
2) && "min-h-50" )} ref={scrollRef} >
{rows.length ? ( virtualRows.map((virtualRow) => { const row = rows[virtualRow.index] return }) ) : ( {data ? t`No results.` : } )}
) }) function SmartTableHead({ table }: { table: TableType }) { return (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ))} ))}
) } const SmartDeviceTableRow = memo(function SmartDeviceTableRow({ row, virtualRow, openSheet, }: { row: Row virtualRow: VirtualItem openSheet: (disk: SmartDeviceRecord) => void }) { return ( openSheet(row.original)} > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) }) 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 const firmwareVersion = disk?.firmware const status = disk?.state || unknown return ( S.M.A.R.T. Details - {deviceName} {model} {capacity} {serialNumber && ( <> {serialNumber} Serial Number )} {firmwareVersion && ( <> {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.
)} )}
) }