import { SystemRecord } from "@/types" import { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table" import { ClassValue } from "clsx" import { ArrowUpDownIcon, CopyIcon, CpuIcon, HardDriveIcon, MemoryStickIcon, MoreHorizontalIcon, PauseCircleIcon, PenBoxIcon, PlayCircleIcon, ServerIcon, Trash2Icon, WifiIcon, } from "lucide-react" import { Button } from "../ui/button" import { cn, copyToClipboard, decimalString, formatBytes, formatTemperature, getMeterState, parseSemVer, } from "@/lib/utils" import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons" import { useStore } from "@nanostores/react" import { $longestSystemNameLen, $userSettings } from "@/lib/stores" import { Trans, useLingui } from "@lingui/react/macro" import { useMemo, useRef, useState } from "react" import { memo } from "react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu" import AlertButton from "../alerts/alert-button" import { Dialog } from "../ui/dialog" import { SystemDialog } from "../add-system" import { AlertDialog } from "../ui/alert-dialog" import { AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "../ui/alert-dialog" import { buttonVariants } from "../ui/button" import { t } from "@lingui/core/macro" import { MeterState, SystemStatus } from "@/lib/enums" import { $router, Link } from "../router" import { getPagePath } from "@nanostores/router" import { isReadOnlyUser, pb } from "@/lib/api" 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 default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef[] { return [ { size: 200, minSize: 0, accessorKey: "name", id: "system", name: () => t`System`, 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 } = info.row.original const longestName = useStore($longestSystemNameLen) return ( <> {name} ) }, header: sortableHeader, }, { accessorFn: ({ info }) => info.cpu, id: "cpu", name: () => t`CPU`, cell: TableCellWithMeter, Icon: CpuIcon, header: sortableHeader, }, { // accessorKey: "info.mp", accessorFn: ({ info }) => info.mp, id: "memory", name: () => t`Memory`, cell: TableCellWithMeter, Icon: MemoryStickIcon, header: sortableHeader, }, { accessorFn: ({ info }) => info.dp, id: "disk", name: () => t`Disk`, cell: TableCellWithMeter, Icon: HardDriveIcon, header: sortableHeader, }, { accessorFn: ({ info }) => info.g, 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) } return sum }, 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, 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() 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.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[] } function sortableHeader(context: HeaderContext) { const { column } = context // @ts-ignore const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef 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)}%
) } 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) 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 === 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]) })