import { CellContext, ColumnDef, ColumnFiltersState, getFilteredRowModel, SortingState, getSortedRowModel, flexRender, VisibilityState, getCoreRowModel, useReactTable, HeaderContext, Row, Table as TableType, } from "@tanstack/react-table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Button, buttonVariants } from "@/components/ui/button" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { SystemRecord } from "@/types" import { MoreHorizontalIcon, ArrowUpDownIcon, MemoryStickIcon, CopyIcon, PauseCircleIcon, PlayCircleIcon, Trash2Icon, WifiIcon, HardDriveIcon, ServerIcon, CpuIcon, LayoutGridIcon, LayoutListIcon, ArrowDownIcon, ArrowUpIcon, Settings2Icon, EyeIcon, PenBoxIcon, } from "lucide-react" import { memo, useEffect, useMemo, useRef, useState } from "react" import { $systems, $userSettings, pb } from "@/lib/stores" import { useStore } from "@nanostores/react" import { cn, copyToClipboard, isReadOnlyUser, useLocalStorage, formatTemperature, decimalString, formatBytes, } from "@/lib/utils" import AlertsButton from "../alerts/alert-button" import { $router, Link, navigate } from "../router" import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons" import { useLingui, Trans } from "@lingui/react/macro" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Input } from "../ui/input" import { ClassValue } from "clsx" import { getPagePath } from "@nanostores/router" import { SystemDialog } from "../add-system" import { Dialog } from "../ui/dialog" type ViewMode = "table" | "grid" function CellFormatter(info: CellContext) { const val = (info.getValue() as number) || 0 return (
{decimalString(val, 1)}%
) } function sortableHeader(context: HeaderContext) { const { column } = context // @ts-ignore const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef return ( ) } export default function SystemsTable() { const data = useStore($systems) const { i18n, t } = useLingui() const [filter, setFilter] = useState() const [sorting, setSorting] = useState([{ id: "system", desc: false }]) const [columnFilters, setColumnFilters] = useState([]) const [columnVisibility, setColumnVisibility] = useLocalStorage("cols", {}) const [viewMode, setViewMode] = useLocalStorage("viewMode", window.innerWidth > 1024 ? "table" : "grid") const locale = i18n.locale useEffect(() => { if (filter !== undefined) { table.getColumn("system")?.setFilterValue(filter) } }, [filter]) const columnDefs = useMemo(() => { const statusTranslations = { up: () => t`Up`.toLowerCase(), down: () => t`Down`.toLowerCase(), paused: () => t`Paused`.toLowerCase(), } return [ { size: 200, minSize: 0, accessorKey: "name", id: "system", name: () => t`System`, filterFn: (row, _, filterVal) => { const filterLower = filterVal.toLowerCase() const { name, status } = row.original // Check if the filter matches the name or status for this row if ( name.toLowerCase().includes(filterLower) || statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower) ) { return true } return false }, enableHiding: false, invertSorting: false, Icon: ServerIcon, cell: (info) => ( ), header: sortableHeader, }, { accessorFn: ({ info }) => decimalString(info.cpu, info.cpu >= 10 ? 1 : 2), id: "cpu", name: () => t`CPU`, cell: CellFormatter, Icon: CpuIcon, header: sortableHeader, }, { // accessorKey: "info.mp", accessorFn: (originalRow) => originalRow.info.mp, id: "memory", name: () => t`Memory`, cell: CellFormatter, Icon: MemoryStickIcon, header: sortableHeader, }, { accessorFn: (originalRow) => originalRow.info.dp, id: "disk", name: () => t`Disk`, cell: CellFormatter, Icon: HardDriveIcon, header: sortableHeader, }, { accessorFn: (originalRow) => originalRow.info.g, id: "gpu", name: () => "GPU", cell: CellFormatter, Icon: GpuIcon, header: sortableHeader, }, { id: "loadAverage", accessorFn: ({ info }) => { const { l1 = 0, l5 = 0, l15 = 0 } = info return l1 + l5 + l15 }, name: () => t({ message: "Load Avg", comment: "Short label for load average" }), size: 0, Icon: HourglassIcon, header: sortableHeader, cell(info: CellContext) { const { info: sysInfo, status } = info.row.original if (sysInfo.l1 === undefined) { return null } const { l1 = 0, l5 = 0, l15 = 0, t: cpuThreads = 1 } = sysInfo const loadAverages = [l1, l5, l15] function getDotColor() { const max = Math.max(...loadAverages) const normalized = max / cpuThreads if (status !== "up") return "bg-primary/30" if (normalized < 0.7) return "bg-green-500" if (normalized < 1) return "bg-yellow-500" return "bg-red-600" } return (
{loadAverages.map((la, i) => ( {decimalString(la, la >= 10 ? 1 : 2)} ))}
) }, }, { accessorFn: (originalRow) => originalRow.info.b || 0, id: "net", name: () => t`Net`, size: 0, Icon: EthernetIcon, header: sortableHeader, cell(info) { if (info.row.original.status === "paused") { return null } const val = info.getValue() as number const userSettings = useStore($userSettings) const { value, unit } = formatBytes(val, true, userSettings.unitNet, true) return ( {decimalString(value, value >= 100 ? 1 : 2)} {unit} ) }, }, { accessorFn: (originalRow) => originalRow.info.dt, id: "temp", name: () => t({ message: "Temp", comment: "Temperature label in systems table" }), size: 50, hideSort: true, Icon: ThermometerIcon, header: sortableHeader, cell(info) { const val = info.getValue() as number if (!val) { return null } const userSettings = useStore($userSettings) const { value, unit } = formatTemperature(val, userSettings.unitTemp) return ( {decimalString(value, value >= 100 ? 1 : 2)} {unit} ) }, }, { accessorFn: (originalRow) => originalRow.info.v, id: "agent", name: () => t`Agent`, // invertSorting: true, size: 50, Icon: WifiIcon, hideSort: true, header: sortableHeader, cell(info) { const version = info.getValue() as string if (!version) { return null } const system = info.row.original return ( {info.getValue() as string} ) }, }, { id: "actions", // @ts-ignore name: () => t({ message: "Actions", comment: "Table column" }), size: 50, cell: ({ row }) => (
), }, ] as ColumnDef[] }, []) const table = useReactTable({ data, columns: columnDefs, getCoreRowModel: getCoreRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, state: { sorting, columnFilters, columnVisibility, }, defaultColumn: { // sortDescFirst: true, invertSorting: true, sortUndefined: "last", minSize: 0, size: 900, maxSize: 900, }, }) const rows = table.getRowModel().rows const columns = table.getAllColumns() const visibleColumns = table.getVisibleLeafColumns() // TODO: hiding temp then gpu messes up table headers const CardHead = useMemo(() => { return (
All Systems Updated in real time. Click on a system to view information.
setFilter(e.target.value)} className="px-4" />
Layout setViewMode(view as ViewMode)} > e.preventDefault()} className="gap-2"> Table e.preventDefault()} className="gap-2"> Grid
Sort By
{columns.map((column) => { if (!column.getCanSort()) return null let Icon = // if current sort column, show sort direction if (sorting[0]?.id === column.id) { if (sorting[0]?.desc) { Icon = } else { Icon = } } return ( { e.preventDefault() setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }]) }} key={column.id} > {Icon} {/* @ts-ignore */} {column.columnDef.name()} ) })}
Visible Fields
{columns .filter((column) => column.getCanHide()) .map((column) => { return ( e.preventDefault()} checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)} > {/* @ts-ignore */} {column.columnDef.name()} ) })}
) }, [visibleColumns.length, sorting, viewMode, locale]) return ( {CardHead}
{viewMode === "table" ? ( // table layout
) : ( // grid layout
{rows?.length ? ( rows.map((row) => { return }) ) : (
No systems found.
)}
)}
) } const AllSystemsTable = memo( ({ table, rows, colLength }: { table: TableType; rows: Row[]; colLength: number }) => { return ( {rows.length ? ( rows.map((row) => ( )) ) : ( No systems found. )}
) } ) function SystemsTableHead({ table, colLength }: { table: TableType; colLength: number }) { const { i18n } = useLingui() return useMemo(() => { return ( {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( {flexRender(header.column.columnDef.header, header.getContext())} ) })} ))} ) }, [i18n.locale, colLength]) } const SystemTableRow = memo( ({ row, length, colLength }: { row: Row; length: number; colLength: number }) => { const system = row.original const { t } = useLingui() return useMemo(() => { return ( { const target = e.target as HTMLElement if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) { navigate(getPagePath($router, "system", { name: system.name })) } }} > {row.getVisibleCells().map((cell) => ( 10 ? "py-2" : "py-2.5")} > {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) }, [system, system.status, colLength, t]) } ) const SystemCard = memo( ({ row, table, colLength }: { row: Row; table: TableType; colLength: number }) => { const system = row.original const { t } = useLingui() return useMemo(() => { return (
{system.name}
{table.getColumn("actions")?.getIsVisible() && (
)}
{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 return (
{Icon && }
{name()}:
{flexRender(cell.column.columnDef.cell, cell.getContext())}
) })}
{row.original.name}
) }, [system, colLength, t]) } ) const ActionsButton = memo(({ system }: { system: SystemRecord }) => { const [deleteOpen, setDeleteOpen] = useState(false) const [editOpen, setEditOpen] = useState(false) let editOpened = useRef(false) const { t } = useLingui() const { id, status, host, name } = system return useMemo(() => { return ( <> {!isReadOnlyUser() && ( { editOpened.current = true setEditOpen(true) }} > Edit )} { pb.collection("systems").update(id, { status: status === "paused" ? "pending" : "paused", }) }} > {status === "paused" ? ( <> Resume ) : ( <> Pause )} copyToClipboard(host)}> Copy host setDeleteOpen(true)}> Delete {/* edit dialog */} {editOpened.current && } {/* deletion dialog */} setDeleteOpen(open)}> Are you sure you want to delete {name}? This action cannot be undone. This will permanently delete all current records for {name} from the database. Cancel pb.collection("systems").delete(id)} > Continue ) }, [id, status, host, name, t, deleteOpen, editOpen]) }) function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) { className ||= { "bg-green-500": system.status === "up", "bg-red-500": system.status === "down", "bg-primary/40": system.status === "paused", "bg-yellow-500": system.status === "pending", } return ( ) }