import { t } from "@lingui/core/macro" import { Trans, useLingui } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import type { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table" import type { ClassValue } from "clsx" import { ArrowUpDownIcon, ChevronRightSquareIcon, CopyIcon, CpuIcon, HardDriveIcon, MemoryStickIcon, MoreHorizontalIcon, PauseCircleIcon, PenBoxIcon, PlayCircleIcon, ServerIcon, TerminalSquareIcon, Trash2Icon, WifiIcon, } from "lucide-react" import { memo, useMemo, useRef, useState } from "react" import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip" import { isReadOnlyUser, pb } from "@/lib/api" import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums" import { $longestSystemNameLen, $userSettings } from "@/lib/stores" import { cn, copyToClipboard, decimalString, formatBytes, formatTemperature, getMeterState, parseSemVer, } from "@/lib/utils" import type { SystemRecord } from "@/types" import { SystemDialog } from "../add-system" import AlertButton from "../alerts/alert-button" import { $router, Link } from "../router" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "../ui/alert-dialog" import { Button, buttonVariants } from "../ui/button" import { Dialog } from "../ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu" import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon, WebSocketIcon } from "../ui/icons" const STATUS_COLORS = { [SystemStatus.Up]: "bg-green-500", [SystemStatus.Down]: "bg-red-500", [SystemStatus.Paused]: "bg-primary/40", [SystemStatus.Pending]: "bg-yellow-500", } as const /** * @param viewMode - "table" or "grid" * @returns - Column definitions for the systems table */ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef[] { return [ { // size: 200, size: 100, minSize: 0, accessorKey: "name", id: "system", name: () => t`System`, sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), filterFn: (() => { let filterInput = "" let filterInputLower = "" const nameCache = new Map() const statusTranslations = { [SystemStatus.Up]: t`Up`.toLowerCase(), [SystemStatus.Down]: t`Down`.toLowerCase(), [SystemStatus.Paused]: t`Paused`.toLowerCase(), } as const // match filter value against name or translated status return (row, _, newFilterInput) => { const { name, status } = row.original if (newFilterInput !== filterInput) { filterInput = newFilterInput filterInputLower = newFilterInput.toLowerCase() } let nameLower = nameCache.get(name) if (nameLower === undefined) { nameLower = name.toLowerCase() nameCache.set(name, nameLower) } if (nameLower.includes(filterInputLower)) { return true } const statusLower = statusTranslations[status as keyof typeof statusTranslations] return statusLower?.includes(filterInputLower) || false } })(), enableHiding: false, invertSorting: false, Icon: ServerIcon, cell: (info) => { const { name, id } = info.row.original const longestName = useStore($longestSystemNameLen) return ( <> {/* NOTE: change to 1 ch if switching to monospace font */} {name} ) }, header: sortableHeader, }, { accessorFn: ({ info }) => info.cpu || undefined, id: "cpu", name: () => t`CPU`, cell: TableCellWithMeter, Icon: CpuIcon, header: sortableHeader, }, { // accessorKey: "info.mp", accessorFn: ({ info }) => info.mp || undefined, id: "memory", name: () => t`Memory`, cell: TableCellWithMeter, Icon: MemoryStickIcon, header: sortableHeader, }, { accessorFn: ({ info }) => info.dp || undefined, id: "disk", name: () => t`Disk`, cell: DiskCellWithMultiple, Icon: HardDriveIcon, header: sortableHeader, }, { accessorFn: ({ info }) => info.g || undefined, id: "gpu", name: () => "GPU", cell: TableCellWithMeter, Icon: GpuIcon, header: sortableHeader, }, { id: "loadAverage", accessorFn: ({ info }) => { const sum = info.la?.reduce((acc, curr) => acc + curr, 0) // TODO: remove this in future release in favor of la array if (!sum) { return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0) || undefined } return sum || undefined }, 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 // agent version const { minor, patch } = parseSemVer(sysInfo.v) let loadAverages = sysInfo.la // use legacy load averages if agent version is less than 12.1.0 if (!loadAverages || (minor === 12 && patch < 1)) { loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0] } const max = Math.max(...loadAverages) if (max === 0 && (status === SystemStatus.Paused || minor < 12)) { return null } const normalizedLoad = max / (sysInfo.t ?? 1) const threshold = getMeterState(normalizedLoad * 100) return (
{loadAverages?.map((la, i) => ( {decimalString(la, la >= 10 ? 1 : 2)} ))}
) }, }, { accessorFn: ({ info }) => (info.bb || (info.b || 0) * 1024 * 1024) || undefined, id: "net", name: () => t`Net`, size: 0, Icon: EthernetIcon, header: sortableHeader, cell(info) { const sys = info.row.original const userSettings = useStore($userSettings, { keys: ["unitNet"] }) if (sys.status === SystemStatus.Paused) { return null } const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false) return ( {decimalString(value , value >= 100 ? 1 : 2)} {unit} ) }, }, { accessorFn: ({ info }) => 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 const userSettings = useStore($userSettings, { keys: ["unitTemp"] }) if (!val) { return null } const { value, unit } = formatTemperature(val, userSettings.unitTemp) return ( {decimalString(value, value >= 100 ? 1 : 2)} {unit} ) }, }, { accessorFn: ({ info }) => info.sv?.[0], id: "services", name: () => t`Services`, size: 50, Icon: TerminalSquareIcon, header: sortableHeader, hideSort: true, sortingFn: (a, b) => { // sort priorities: 1) failed services, 2) total services const [totalCountA, numFailedA] = a.original.info.sv ?? [0, 0] const [totalCountB, numFailedB] = b.original.info.sv ?? [0, 0] if (numFailedA !== numFailedB) { return numFailedA - numFailedB } return totalCountA - totalCountB }, cell(info) { const sys = info.row.original const [totalCount, numFailed] = sys.info.sv ?? [0, 0] if (sys.status !== SystemStatus.Up || totalCount === 0) { return null } return ( 0, [STATUS_COLORS[SystemStatus.Up]]: numFailed === 0, })} /> {totalCount} ({t`Failed`.toLowerCase()}: {numFailed}) ) }, }, { accessorFn: ({ info }) => info.v, id: "agent", name: () => t`Agent`, 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 const color = { "text-green-500": version === globalThis.BESZEL.HUB_VERSION, "text-yellow-500": version !== globalThis.BESZEL.HUB_VERSION, "text-red-500": system.status !== SystemStatus.Up, } return ( {system.info.ct === ConnectionType.WebSocket && ( )} {system.info.ct === ConnectionType.SSH && ( )} {!system.info.ct && } {info.getValue() as string} ) }, }, { id: "actions", // @ts-expect-error name: () => t({ message: "Actions", comment: "Table column" }), size: 50, cell: ({ row }) => (
), }, ] as ColumnDef[] } function sortableHeader(context: HeaderContext) { const { column } = context // @ts-expect-error const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef const isSorted = column.getIsSorted() return ( ) } function TableCellWithMeter(info: CellContext) { const val = Number(info.getValue()) || 0 const threshold = getMeterState(val) const meterClass = cn( "h-full", (info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) || (threshold === MeterState.Good && STATUS_COLORS.up) || (threshold === MeterState.Warn && STATUS_COLORS.pending) || STATUS_COLORS.down ) return (
{decimalString(val, val >= 10 ? 1 : 2)}%
) } function DiskCellWithMultiple(info: CellContext) { const { info: sysInfo, status, id } = info.row.original const extraFs = Object.entries(sysInfo.efs ?? {}) // No extra disks - show basic meter if (extraFs.length === 0) { return TableCellWithMeter(info) } const rootDiskPct = sysInfo.dp // sort extra disks by percentage descending extraFs.sort((a, b) => b[1] - a[1]) function getMeterClass(pct: number) { const threshold = getMeterState(pct) return cn( "h-full", (status !== SystemStatus.Up && STATUS_COLORS.paused) || (threshold === MeterState.Good && STATUS_COLORS.up) || (threshold === MeterState.Warn && STATUS_COLORS.pending) || STATUS_COLORS.down ) } return (
{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}% {/* Root disk */} {/* Extra disks */} {extraFs.map(([_name, pct], index) => ( ))}
Root
{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%
{extraFs.map(([name, pct]) => { return (
{name}
{decimalString(pct, pct >= 10 ? 1 : 2)}%
) })}
) } export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) { className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || "" return ( ) } export const ActionsButton = memo(({ system }: { system: SystemRecord }) => { const [deleteOpen, setDeleteOpen] = useState(false) const [editOpen, setEditOpen] = useState(false) const 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 === SystemStatus.Paused ? SystemStatus.Pending : SystemStatus.Paused, }) }} > {status === SystemStatus.Paused ? ( <> Resume ) : ( <> Pause )} copyToClipboard(name)}> Copy name 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]) })