import * as React from "react" import { t } from "@lingui/core/macro" import { ColumnDef, ColumnFiltersState, Column, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, SortingState, useReactTable, } from "@tanstack/react-table" import { Activity, Box, Clock, HardDrive, HashIcon, CpuIcon, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon } 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 { SmartData, SmartAttribute } from "@/types" import { formatBytes, toFixedFloat, formatTemperature, cn, secondsToString } from "@/lib/utils" import { Trans } from "@lingui/react/macro" import { ThermometerIcon } from "@/components/ui/icons" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Separator } from "@/components/ui/separator" // 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 = { device: string model: string serialNumber: string firmwareVersion: string capacity: string status: string temperature: number deviceType: string powerOnHours?: number powerCycles?: number } // 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 SmartData to DiskInfo function convertSmartDataToDiskInfo(smartDataRecord: Record): DiskInfo[] { const unknown = "Unknown" return Object.entries(smartDataRecord).map(([key, smartData]) => ({ device: smartData.dn || key, model: smartData.mn || unknown, serialNumber: smartData.sn || unknown, firmwareVersion: smartData.fv || unknown, capacity: smartData.c ? formatCapacity(smartData.c) : unknown, status: smartData.s || unknown, temperature: smartData.t || 0, deviceType: smartData.dt || unknown, // These fields need to be extracted from SmartAttribute if available powerOnHours: smartData.a?.find(attr => { const name = attr.n.toLowerCase(); return name.includes("poweronhours") || name.includes("power_on_hours"); })?.rv, powerCycles: smartData.a?.find(attr => { const name = attr.n.toLowerCase(); return (name.includes("power") && name.includes("cycle")) || name.includes("startstopcycles"); })?.rv, })) } export const columns: ColumnDef[] = [ { 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: "temperature", invertSorting: true, header: ({ column }) => , cell: ({ getValue }) => { const { value, unit } = formatTemperature(getValue() as number) return {`${value} ${unit}`} }, }, { 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: "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} ), }, ] 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] = React.useState([{ id: "device", desc: false }]) const [columnFilters, setColumnFilters] = React.useState([]) const [rowSelection, setRowSelection] = React.useState({}) const [smartData, setSmartData] = React.useState | undefined>(undefined) const [activeDisk, setActiveDisk] = React.useState(null) const [sheetOpen, setSheetOpen] = React.useState(false) const openSheet = (disk: DiskInfo) => { setActiveDisk(disk) setSheetOpen(true) } // Fetch smart data when component mounts or systemId changes React.useEffect(() => { if (systemId) { pb.send>("/api/beszel/smart", { query: { system: systemId } }) .then((data) => { setSmartData(data) }) .catch(() => setSmartData({})) } }, [systemId]) // Convert SmartData to DiskInfo, if no data use empty array const diskData = React.useMemo(() => { return smartData ? convertSmartDataToDiskInfo(smartData) : [] }, [smartData]) const table = useReactTable({ data: diskData, columns: columns, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), onRowSelectionChange: setRowSelection, state: { sorting, columnFilters, rowSelection, }, }) if (!diskData.length && !columnFilters.length) { return null } return (
S.M.A.R.T. Click on a device to view more information.
table.getColumn("device")?.setFilterValue(event.target.value) } className="ms-auto px-4 w-full max-w-full md:w-64" />
{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() )} ))} )) ) : ( {smartData ? t`No results.` : } )}
) } function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | null; smartData?: SmartData; open: boolean; onOpenChange: (open: boolean) => void }) { if (!disk) return null const smartAttributes = smartData?.a || [] // 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 = React.useMemo(() => { return smartColumns.filter(column => { const accessorKey = (column as any).accessorKey as keyof SmartAttribute 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(), }) return ( S.M.A.R.T. Details - {disk.device} {disk.model} {disk.serialNumber}
{smartData?.s === "PASSED" ? ( ) : ( )} S.M.A.R.T. Self-Test: {smartData?.s} {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.
)}
) }