Virtualize All Systems table to improve performance with hundreds of systems (#1100)

- Also truncate long system names in tables and alerts sheet. (#1104)
This commit is contained in:
henrygd
2025-08-29 16:16:45 -04:00
parent 0e95caaee9
commit 96f441de40
20 changed files with 176 additions and 175 deletions

Binary file not shown.

View File

@@ -33,6 +33,7 @@
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View File

@@ -133,7 +133,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
>
<Tabs defaultValue={tab} onValueChange={setTab}>
<DialogHeader>
<DialogTitle className="mb-2">
<DialogTitle className="mb-2 max-w-100 truncate pr-8">
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
</DialogTitle>
<TabsList className="grid w-full grid-cols-2">

View File

@@ -16,7 +16,9 @@ export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
<Trans>System</Trans>
</Button>
),
cell: ({ row }) => <span className="ps-2">{row.original.expand?.system?.name || row.original.system}</span>,
cell: ({ row }) => (
<div className="ps-2 max-w-60 truncate">{row.original.expand?.system?.name || row.original.system}</div>
),
filterFn: (row, _, filterValue) => {
const display = row.original.expand?.system?.name || row.original.system || ""
return display.toLowerCase().includes(filterValue.toLowerCase())

View File

@@ -96,7 +96,7 @@ export const AlertDialogContent = memo(function AlertDialogContent({ system }: {
<TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="system">
<ServerIcon className="me-2 h-3.5 w-3.5" />
{system.name}
<span className="truncate max-w-60">{system.name}</span>
</TabsTrigger>
<TabsTrigger value="global">
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />

View File

@@ -44,7 +44,7 @@ export default memo(function () {
<SystemsTable />
</Suspense>
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80">
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
<a
href="https://github.com/henrygd/beszel"
target="_blank"

View File

@@ -273,13 +273,13 @@ export default function AlertsHistoryDataTable() {
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<tr key={headerGroup.id} className="border-border/50">
{headerGroup.headers.map((header) => (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
</tr>
))}
</TableHeader>
<TableBody>

View File

@@ -102,7 +102,7 @@ export default function SettingsLayout() {
}, [])
return (
<Card className="pt-5 px-4 pb-8 min-h-96 sm:pt-6 sm:px-7">
<Card className="pt-5 px-4 pb-8 min-h-96 mb-14 sm:pt-6 sm:px-7">
<CardHeader className="p-0">
<CardTitle className="mb-1">
<Trans>Settings</Trans>

View File

@@ -272,7 +272,7 @@ const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRec
<div className="rounded-md border overflow-hidden w-full mt-4">
<Table>
<TableHeader>
<TableRow>
<tr className="border-border/50">
{headerCols.map((col) => (
<TableHead key={col.label} style={{ minWidth: col.w }}>
<span className="flex items-center gap-2">
@@ -288,12 +288,14 @@ const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRec
</span>
</TableHead>
)}
</TableRow>
</tr>
</TableHeader>
<TableBody className="whitespace-pre">
{fingerprints.map((fingerprint, i) => (
<TableRow key={i}>
<TableCell className="font-medium ps-5 py-2">{fingerprint.expand.system.name}</TableCell>
<TableCell className="font-medium ps-5 py-2 max-w-60 truncate">
{fingerprint.expand.system.name}
</TableCell>
<TableCell className="font-mono text-[0.95em] py-2">{fingerprint.token}</TableCell>
<TableCell className="font-mono text-[0.95em] py-2">{fingerprint.fingerprint}</TableCell>
{!isReadOnly && (

View File

@@ -391,7 +391,7 @@ export default function SystemDetail({ name }: { name: string }) {
return (
<>
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
<div id="chartwrap" className="grid gap-4 mb-14 overflow-x-clip">
{/* system info */}
<Card>
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">

View File

@@ -27,7 +27,7 @@ import {
} from "@/lib/utils"
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
import { useStore } from "@nanostores/react"
import { $userSettings } from "@/lib/stores"
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
import { Trans, useLingui } from "@lingui/react/macro"
import { useMemo, useRef, useState } from "react"
import { memo } from "react"
@@ -111,11 +111,14 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
Icon: ServerIcon,
cell: (info) => {
const { name } = info.row.original
const longestName = useStore($longestSystemNameLen)
return (
<>
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
<IndicatorDot system={info.row.original} />
{name}
<span className="truncate" style={{ width: `${longestName}ch` }}>
{name}
</span>
</span>
<Link
href={getPagePath($router, "system", { name })}

View File

@@ -11,7 +11,7 @@ import {
Row,
Table as TableType,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
@@ -35,7 +35,7 @@ import {
EyeIcon,
FilterIcon,
} from "lucide-react"
import { memo, useEffect, useMemo, useState } from "react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { $systems } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { cn, runOnce, useLocalStorage } from "@/lib/utils"
@@ -47,6 +47,7 @@ import { getPagePath } from "@nanostores/router"
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
import AlertButton from "../alerts/alert-button"
import { SystemStatus } from "@/lib/enums"
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
type ViewMode = "table" | "grid"
type StatusFilter = "all" | "up" | "down" | "paused"
@@ -255,7 +256,7 @@ export default function SystemsTable() {
<div className="p-6 pt-0 max-sm:py-3 max-sm:px-2">
{viewMode === "table" ? (
// table layout
<div className="rounded-md border overflow-hidden">
<div className="rounded-md">
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
) : (
@@ -277,36 +278,78 @@ export default function SystemsTable() {
)
}
const AllSystemsTable = memo(
({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
return (
<Table>
<SystemsTableHead table={table} colLength={colLength} />
<TableBody onMouseEnter={preloadSystemDetail}>
{rows.length ? (
rows.map((row) => (
<SystemTableRow key={row.original.id} row={row} length={rows.length} colLength={colLength} />
))
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-24 text-center">
<Trans>No systems found.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}
)
const AllSystemsTable = memo(function ({
table,
rows,
colLength,
}: {
table: TableType<SystemRecord>
rows: Row<SystemRecord>[]
colLength: number
}) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => (rows.length > 10 ? 56 : 60),
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 border rounded-md",
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
{/* add header height to table size */}
<div style={{ height: `${virtualizer.getTotalSize() + 50}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full">
<SystemsTableHead table={table} colLength={colLength} />
<TableBody onMouseEnter={preloadSystemDetail}>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index] as Row<SystemRecord>
return (
<SystemTableRow
key={row.id}
row={row}
virtualRow={virtualRow}
length={rows.length}
colLength={colLength}
/>
)
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
<Trans>No systems found.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
</div>
)
})
function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>; colLength: number }) {
const { i18n } = useLingui()
return useMemo(() => {
return (
<TableHeader>
<TableHeader className="sticky top-0 z-20 w-full">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<tr key={headerGroup.id} className="border-border/50">
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-1.5" key={header.id}>
@@ -314,41 +357,49 @@ function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>
</TableHead>
)
})}
</TableRow>
</tr>
))}
</TableHeader>
)
}, [i18n.locale, colLength])
}
const SystemTableRow = memo(
({ row, length, colLength }: { row: Row<SystemRecord>; length: number; colLength: number }) => {
const system = row.original
const { t } = useLingui()
return useMemo(() => {
return (
<TableRow
// data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
"opacity-50": system.status === SystemStatus.Paused,
})}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
}}
className={length > 10 ? "py-2" : "py-2.5"}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}, [system, system.status, colLength, t])
}
)
const SystemTableRow = memo(function ({
row,
virtualRow,
colLength,
}: {
row: Row<SystemRecord>
virtualRow: VirtualItem
length: number
colLength: number
}) {
const system = row.original
const { t } = useLingui()
return useMemo(() => {
return (
<TableRow
// data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
"opacity-50": system.status === SystemStatus.Paused,
})}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
height: virtualRow.size,
}}
className="py-0"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}, [system, system.status, colLength, t])
})
const SystemCard = memo(
({ row, table, colLength }: { row: Row<SystemRecord>; table: TableType<SystemRecord>; colLength: number }) => {
@@ -368,13 +419,11 @@ const SystemCard = memo(
)}
>
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-w-0 gap-2.5">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex items-center gap-2 w-full overflow-hidden">
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<IndicatorDot system={system} />
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
{system.name}
</CardTitle>
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
</div>
</CardTitle>
{table.getColumn("actions")?.getIsVisible() && (

View File

@@ -101,7 +101,7 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[selected=true]:bg-accent/70 data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@@ -13,7 +13,11 @@ Table.displayName = "Table"
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn("bg-muted/30 [&_tr]:border-b", className)} {...props} />
<thead
ref={ref}
className={cn("bg-table-header border-b border-border/50 [&_tr]:border-b", className)}
{...props}
/>
)
)
TableHeader.displayName = "TableHeader"

View File

@@ -1,14 +1,15 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant light (&:is(.light *));
@custom-variant dark (&:is(.dark *));
@custom-variant safari (@supports (hanging-punctuation: first) and (-webkit-appearance: none));
:root {
--background: hsl(30 8% 98%);
--foreground: hsl(30 0% 0%);
--foreground: hsl(30 0% 10%);
--card: hsl(30 0% 100%);
--card-foreground: hsl(240 6.67% 2.94%);
--card-foreground: hsl(240 6% 12%);
--popover: hsl(30 0% 100%);
--popover-foreground: hsl(240 10% 6.2%);
--primary: hsl(240 5.88% 10%);
@@ -30,6 +31,7 @@
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--table-header: hsl(225, 6%, 97%);
}
.dark {
@@ -53,6 +55,7 @@
--border: hsl(220 3% 16%);
--input: hsl(220 4% 22%);
--ring: hsl(220 4% 80%);
--table-header: hsl(220, 6%, 13%);
--radius: 0.8rem;
}
@@ -103,6 +106,7 @@
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-table-header: var(--table-header);
}
@layer utilities {

View File

@@ -1,5 +1,5 @@
import { ChartTimes, SystemRecord, UserSettings } from "@/types"
import { $alerts, $systems, $userSettings } from "./stores"
import { $alerts, $longestSystemNameLen, $systems, $userSettings } from "./stores"
import { toast } from "@/components/ui/use-toast"
import { t } from "@lingui/core/macro"
import { chartTimeData } from "./utils"
@@ -88,11 +88,29 @@ export const updateSystemList = (() => {
}
isFetchingSystems = true
try {
const records = await pb
let records = await pb
.collection<SystemRecord>("systems")
.getFullList({ sort: "+name", fields: "id,name,host,port,info,status" })
if (records.length) {
// records = [
// ...records,
// ...records,
// ...records,
// ...records,
// ...records,
// ...records,
// ...records,
// ...records,
// ...records,
// ]
// we need to loop once to get the longest name
let longestName = $longestSystemNameLen.get()
for (const { name } of records) {
if (name.length > longestName) {
$longestSystemNameLen.set(Math.min(20, name.length))
}
}
$systems.set(records)
} else {
verifyAuth()

View File

@@ -53,3 +53,8 @@ export const $copyContent = atom("")
/** Direction for localization */
export const $direction = atom<"ltr" | "rtl">("ltr")
/** Longest system name length. Used to set table column width. I know this
* is stupid but the table is virtualized and I know this will work.
*/
export const $longestSystemNameLen = atom(8)

View File

@@ -102,7 +102,7 @@ const Layout = () => {
<div className="container">
<Navbar />
</div>
<div className="container mb-14 relative">
<div className="container relative">
<App />
{copyContent && (
<Suspense>

View File

@@ -1,93 +0,0 @@
import { memo, useMemo } from "react"
import { Row, TableType } from "@tanstack/react-table"
import { useLingui } from "@lingui/react"
import { cn } from "@/lib/utils"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Link } from "@/components/ui/link"
import { getPagePath } from "@/lib/page-path"
import { useRouter } from "next/router"
import { flexRender } from "@tanstack/react-table"
import { ColumnDef } from "@tanstack/table-core"
import { SystemRecord } from "@/lib/types"
import { IndicatorDot } from "@/components/indicator-dot"
import { AlertsButton } from "@/components/alerts-button"
import { ActionsButton } from "@/components/actions-button"
import { EyeIcon } from "@/components/icons"
const SystemCard = memo(
({ row, table, colLength }: { row: Row<SystemRecord>; table: TableType<SystemRecord>; colLength: number }) => {
const system = row.original
const { t } = useLingui()
return useMemo(() => {
return (
<Card
key={system.id}
className={cn(
"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
{
"opacity-50": system.status === "paused",
}
)}
>
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-w-0 gap-2.5">
<div className="flex items-center gap-2.5 min-w-0">
<IndicatorDot system={system} />
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
{system.name}
</CardTitle>
</div>
</CardTitle>
{table.getColumn("actions")?.getIsVisible() && (
<div className="flex gap-1 flex-shrink-0 relative z-10">
<AlertsButton system={system} />
<ActionsButton system={system} />
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
{table.getAllColumns().map((column) => {
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
if (!cell) return null
// @ts-ignore
const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown>
// Special case for 'lastSeen' column: add EyeIcon before value
if (column.id === "lastSeen") {
return (
<div key={column.id} className="flex items-center gap-3">
<EyeIcon className="size-4 text-muted-foreground" />
<div className="flex items-center gap-3 flex-1">
<span className="text-muted-foreground min-w-16">{name()}:</span>
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
</div>
</div>
)
}
return (
<div key={column.id} className="flex items-center gap-3">
{Icon && <Icon className="size-4 text-muted-foreground" />}
<div className="flex items-center gap-3 flex-1">
<span className="text-muted-foreground min-w-16">{name()}:</span>
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
</div>
</div>
)
})}
</CardContent>
<Link
href={getPagePath($router, "system", { name: row.original.name })}
className="inset-0 absolute w-full h-full"
>
<span className="sr-only">{row.original.name}</span>
</Link>
</Card>
)
}, [system, colLength, t])
}
)

View File

@@ -4,10 +4,16 @@
- Add status filters to All Systems table.
- Virtualize All Systems table to improve performance with hundreds of systems. (#1100)
- Fix Safari system link CSS bug.
- Use older cuda image for increased compatibility (#1103)
- Truncate long system names in All Systems table. (#1104)
- Fix update mirror and add `--china-mirrors` flag. (#1035)
## 0.12.5
- Downgrade `gopsutil` to `v4.25.6` to fix panic on FreeBSD (#1083)