mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 05:36:15 +01:00
ui: virtualize smart table
This commit is contained in:
@@ -3,13 +3,16 @@ import {
|
|||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type ColumnFiltersState,
|
type ColumnFiltersState,
|
||||||
type Column,
|
type Column,
|
||||||
|
type Row,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
|
type Table as TableType,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
|
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Box,
|
Box,
|
||||||
@@ -58,7 +61,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { useCallback, useMemo, useEffect, useState } from "react"
|
import { memo, useCallback, useMemo, useEffect, useRef, useState } from "react"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
// Column definition for S.M.A.R.T. attributes table
|
// Column definition for S.M.A.R.T. attributes table
|
||||||
@@ -102,7 +105,11 @@ function formatCapacity(bytes: number): string {
|
|||||||
|
|
||||||
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
||||||
|
|
||||||
export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>[] => [
|
export const createColumns = (
|
||||||
|
longestName: number,
|
||||||
|
longestModel: number,
|
||||||
|
longestDevice: number
|
||||||
|
): ColumnDef<SmartDeviceRecord>[] => [
|
||||||
{
|
{
|
||||||
id: "system",
|
id: "system",
|
||||||
accessorFn: (record) => record.system,
|
accessorFn: (record) => record.system,
|
||||||
@@ -127,7 +134,11 @@ export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>
|
|||||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="font-medium max-w-40 truncate ms-1.5" title={getValue() as string}>
|
<div
|
||||||
|
className="font-medium max-w-40 truncate ms-1"
|
||||||
|
title={getValue() as string}
|
||||||
|
style={{ width: `${longestDevice / 1.05}ch` }}
|
||||||
|
>
|
||||||
{getValue() as string}
|
{getValue() as string}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -137,7 +148,11 @@ export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>
|
|||||||
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="max-w-48 truncate ms-1.5" title={getValue() as string}>
|
<div
|
||||||
|
className="max-w-48 truncate ms-1"
|
||||||
|
title={getValue() as string}
|
||||||
|
style={{ width: `${longestModel / 1.05}ch` }}
|
||||||
|
>
|
||||||
{getValue() as string}
|
{getValue() as string}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -146,7 +161,7 @@ export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>
|
|||||||
accessorKey: "capacity",
|
accessorKey: "capacity",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
||||||
cell: ({ getValue }) => <span className="ms-1.5">{formatCapacity(getValue() as number)}</span>,
|
cell: ({ getValue }) => <span className="ms-1">{formatCapacity(getValue() as number)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "state",
|
accessorKey: "state",
|
||||||
@@ -154,9 +169,9 @@ export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>
|
|||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const status = getValue() as string
|
const status = getValue() as string
|
||||||
return (
|
return (
|
||||||
<div className="ms-1.5">
|
<Badge className="ms-1" variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>
|
||||||
<Badge variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>{status}</Badge>
|
{status}
|
||||||
</div>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -165,11 +180,9 @@ export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>
|
|||||||
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="ms-1.5">
|
<Badge variant="outline" className="ms-1 uppercase">
|
||||||
<Badge variant="outline" className="uppercase">
|
|
||||||
{getValue() as string}
|
{getValue() as string}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -181,11 +194,11 @@ export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>
|
|||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const hours = getValue() as number | undefined
|
const hours = getValue() as number | undefined
|
||||||
if (hours == null) {
|
if (hours == null) {
|
||||||
return <div className="text-sm text-muted-foreground ms-1.5">N/A</div>
|
return <div className="text-sm text-muted-foreground ms-1">N/A</div>
|
||||||
}
|
}
|
||||||
const seconds = hours * 3600
|
const seconds = hours * 3600
|
||||||
return (
|
return (
|
||||||
<div className="text-sm ms-1.5">
|
<div className="text-sm ms-1">
|
||||||
<div>{secondsToString(seconds, "hour")}</div>
|
<div>{secondsToString(seconds, "hour")}</div>
|
||||||
<div className="text-muted-foreground text-xs">{secondsToString(seconds, "day")}</div>
|
<div className="text-muted-foreground text-xs">{secondsToString(seconds, "day")}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,9 +214,9 @@ export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>
|
|||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const cycles = getValue() as number | undefined
|
const cycles = getValue() as number | undefined
|
||||||
if (cycles == null) {
|
if (cycles == null) {
|
||||||
return <div className="text-muted-foreground ms-1.5">N/A</div>
|
return <div className="text-muted-foreground ms-1">N/A</div>
|
||||||
}
|
}
|
||||||
return <span className="ms-1.5">{cycles.toLocaleString()}</span>
|
return <span className="ms-1">{cycles.toLocaleString()}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -213,10 +226,10 @@ export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>
|
|||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const temp = getValue() as number | null | undefined
|
const temp = getValue() as number | null | undefined
|
||||||
if (!temp) {
|
if (!temp) {
|
||||||
return <div className="text-muted-foreground ms-1.5">N/A</div>
|
return <div className="text-muted-foreground ms-1">N/A</div>
|
||||||
}
|
}
|
||||||
const { value, unit } = formatTemperature(temp)
|
const { value, unit } = formatTemperature(temp)
|
||||||
return <span className="ms-1.5">{`${value} ${unit}`}</span>
|
return <span className="ms-1">{`${value} ${unit}`}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
@@ -241,7 +254,7 @@ export const createColumns = (longestName: number): ColumnDef<SmartDeviceRecord>
|
|||||||
// if today, use hourWithSeconds, otherwise use formatShortDate
|
// if today, use hourWithSeconds, otherwise use formatShortDate
|
||||||
const formatter =
|
const formatter =
|
||||||
new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate
|
new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate
|
||||||
return <span className="ms-1.5 tabular-nums">{formatter(timestamp)}</span>
|
return <span className="ms-1 tabular-nums">{formatter(timestamp)}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -283,26 +296,32 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
const allSystems = useStore($allSystemsById)
|
const allSystems = useStore($allSystemsById)
|
||||||
|
|
||||||
// duplicate the devices to test with more rows
|
// duplicate the devices to test with more rows
|
||||||
// if (smartDevices?.length && smartDevices.length < 50) {
|
// if (
|
||||||
|
// smartDevices?.length &&
|
||||||
|
// smartDevices.length < 50 &&
|
||||||
|
// typeof window !== "undefined" &&
|
||||||
|
// window.location.hostname === "localhost"
|
||||||
|
// ) {
|
||||||
// setSmartDevices([...smartDevices, ...smartDevices, ...smartDevices])
|
// setSmartDevices([...smartDevices, ...smartDevices, ...smartDevices])
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Calculate the right width for the system column based on the longest system name among the displayed devices
|
// Calculate the right width for the columns based on the longest strings among the displayed devices
|
||||||
const longestName = useMemo(() => {
|
const { longestName, longestModel, longestDevice } = useMemo(() => {
|
||||||
if (systemId || !smartDevices || Object.keys(allSystems).length === 0) {
|
const result = { longestName: 0, longestModel: 0, longestDevice: 0 }
|
||||||
return 0
|
if (!smartDevices || Object.keys(allSystems).length === 0) {
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
let maxLen = 0
|
|
||||||
const seenSystems = new Set<string>()
|
const seenSystems = new Set<string>()
|
||||||
for (const device of smartDevices) {
|
for (const device of smartDevices) {
|
||||||
if (seenSystems.has(device.system)) {
|
if (!systemId && !seenSystems.has(device.system)) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenSystems.add(device.system)
|
seenSystems.add(device.system)
|
||||||
const name = allSystems[device.system]?.name ?? ""
|
const name = allSystems[device.system]?.name ?? ""
|
||||||
maxLen = Math.max(maxLen, getVisualStringWidth(name))
|
result.longestName = Math.max(result.longestName, getVisualStringWidth(name))
|
||||||
}
|
}
|
||||||
return maxLen
|
result.longestModel = Math.max(result.longestModel, getVisualStringWidth(device.model ?? ""))
|
||||||
|
result.longestDevice = Math.max(result.longestDevice, getVisualStringWidth(device.name ?? ""))
|
||||||
|
}
|
||||||
|
return result
|
||||||
}, [smartDevices, systemId, allSystems])
|
}, [smartDevices, systemId, allSystems])
|
||||||
|
|
||||||
const openSheet = (disk: SmartDeviceRecord) => {
|
const openSheet = (disk: SmartDeviceRecord) => {
|
||||||
@@ -469,10 +488,10 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
|
|
||||||
// Filter columns based on whether systemId is provided
|
// Filter columns based on whether systemId is provided
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
const columns = createColumns(longestName)
|
const columns = createColumns(longestName, longestModel, longestDevice)
|
||||||
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||||
return [...baseColumns, actionColumn]
|
return [...baseColumns, actionColumn]
|
||||||
}, [systemId, actionColumn, longestName])
|
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: smartDevices || ([] as SmartDeviceRecord[]),
|
data: smartDevices || ([] as SmartDeviceRecord[]),
|
||||||
@@ -504,6 +523,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
.every((term) => searchString.includes(term))
|
.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
|
// Hide the table on system pages if there's no data, but always show on global page
|
||||||
if (systemId && !smartDevices?.length && !columnFilters.length) {
|
if (systemId && !smartDevices?.length && !columnFilters.length) {
|
||||||
@@ -543,57 +563,123 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="rounded-md border text-nowrap overflow-hidden">
|
<SmartDevicesTable
|
||||||
<Table>
|
table={table}
|
||||||
<TableHeader>
|
rows={rows}
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
colLength={tableColumns.length}
|
||||||
<TableRow key={headerGroup.id}>
|
data={smartDevices}
|
||||||
{headerGroup.headers.map((header) => {
|
openSheet={openSheet}
|
||||||
return (
|
/>
|
||||||
<TableHead key={header.id} className="px-2">
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => openSheet(row.original)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id} className="md:ps-5">
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={tableColumns.length} className="h-24 text-center">
|
|
||||||
{smartDevices ? (
|
|
||||||
t`No results.`
|
|
||||||
) : (
|
|
||||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
<DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} />
|
<DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SmartDevicesTable = memo(function SmartDevicesTable({
|
||||||
|
table,
|
||||||
|
rows,
|
||||||
|
colLength,
|
||||||
|
data,
|
||||||
|
openSheet,
|
||||||
|
}: {
|
||||||
|
table: TableType<SmartDeviceRecord>
|
||||||
|
rows: Row<SmartDeviceRecord>[]
|
||||||
|
colLength: number
|
||||||
|
data: SmartDeviceRecord[] | undefined
|
||||||
|
openSheet: (disk: SmartDeviceRecord) => void
|
||||||
|
}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto rounded-md border",
|
||||||
|
(!rows.length || rows.length > 2) && "min-h-50"
|
||||||
|
)}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||||
|
<table className="w-full text-sm text-nowrap">
|
||||||
|
<SmartTableHead table={table} />
|
||||||
|
<TableBody>
|
||||||
|
{rows.length ? (
|
||||||
|
virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index]
|
||||||
|
return <SmartDeviceTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colLength} className="h-24 text-center pointer-events-none">
|
||||||
|
{data ? t`No results.` : <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function SmartTableHead({ table }: { table: TableType<SmartDeviceRecord> }) {
|
||||||
|
return (
|
||||||
|
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||||
|
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id} className="px-2">
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SmartDeviceTableRow = memo(function SmartDeviceTableRow({
|
||||||
|
row,
|
||||||
|
virtualRow,
|
||||||
|
openSheet,
|
||||||
|
}: {
|
||||||
|
row: Row<SmartDeviceRecord>
|
||||||
|
virtualRow: VirtualItem
|
||||||
|
openSheet: (disk: SmartDeviceRecord) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => openSheet(row.original)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className="md:ps-5 py-0"
|
||||||
|
style={{
|
||||||
|
height: virtualRow.size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function DiskSheet({
|
function DiskSheet({
|
||||||
diskId,
|
diskId,
|
||||||
open,
|
open,
|
||||||
|
|||||||
Reference in New Issue
Block a user