"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Center, Group, Loader, Progress, Text } from "@mantine/core"; import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer, Wifi, } from "lucide-react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { useOptionalBoard } from "@homarr/boards/context"; import { useModalAction } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { HomarrDataTable } from "../common/homarr-data-table"; import { getUsableWidgetQueryData } from "../common/query-state"; import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout"; import type { BeszelSystemRow } from "../beszel/_shared/types"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime, getProgressTrackSize, } from "../beszel/_shared/format"; import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; const directionMultiplier: Record = { asc: 1, desc: -1 }; type SystemRowWithKey = BeszelSystemRow & { _key: string }; const columnAccessors = [ "name", "cpu", "memory", "disk", "gpu", "loadAvg", "netBytes", "temp", "battery", "services", "uptime", "agentVersion", ] as const satisfies readonly (keyof BeszelSystemRow)[]; interface SizeConfig { iconSize: number; fontSize: "xs" | "sm"; progressSize: "xs" | "sm"; cellPadding: number; valueMiw: number; } const getSizeConfig = (width: number): SizeConfig => { if (width < 400) { return { iconSize: 10, fontSize: "xs", progressSize: "xs", cellPadding: 2, valueMiw: 30, }; } return { iconSize: 14, fontSize: "sm", progressSize: "sm", cellPadding: 4, valueMiw: 38, }; }; export default function BeszelSystemTableWidget({ options, integrationIds, isEditMode, width, boardId, itemId, setOptions, }: WidgetComponentProps<"beszelSystemTable">) { const t = useScopedI18n("widget.beszelSystemTable"); const { openModal } = useModalAction(BeszelSystemStatsModal); const board = useOptionalBoard(); const { data: session } = useSession(); const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false; const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); const results = getUsableWidgetQueryData(systemsQuery) ?? []; const { isPending } = systemsQuery; const size = useMemo(() => getSizeConfig(width), [width]); const visibleMetricKeys = useMemo(() => { const enabled = [ "showCpu", "showMemory", "showDisk", "showGpu", "showLoadAvg", "showNet", "showTemp", "showBattery", "showServices", "showUptime", "showAgent", ].filter((key) => options[key as keyof typeof options]) as string[]; const budget = width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length; return new Set(enabled.slice(0, budget)); }, [options, width]); const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({ onError: () => showErrorNotification({ title: t("error.layoutSaveTitle"), message: t("error.layoutSaveMessage"), }), }); const persistLayout = useTableLayoutPersistence({ boardId, hasChangeAccess, itemId, saveItemOptions, setOptions, }); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); const [sortStatus, setSortStatus] = useState>({ columnAccessor: options.sortBy, direction: options.sortDirection as "asc" | "desc", }); useEffect(() => { setSortStatus({ columnAccessor: options.sortBy, direction: options.sortDirection as "asc" | "desc", }); }, [options.sortBy, options.sortDirection]); const sortedSystems = useMemo(() => { const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow; const dir = directionMultiplier[sortStatus.direction] ?? 1; return [...filteredSystems].toSorted((a, b) => { const aVal = a[accessor] ?? 0; const bVal = b[accessor] ?? 0; if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir; if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir; return 0; }); }, [filteredSystems, sortStatus]); const PercentCell = ({ value }: { value: number }) => ( {formatPercent(value)} ); const columns = useMemo((): DataTableColumn[] => { const cols: (DataTableColumn | false)[] = [ { accessor: "name", width: 160, ellipsis: true, title: ( {t("column.system")} ), sortable: true, render: (record) => ( {record.name} ), }, visibleMetricKeys.has("showCpu") && { accessor: "cpu", width: 140, title: ( {t("column.cpu")} ), sortable: true, render: (record) => , }, visibleMetricKeys.has("showMemory") && { accessor: "memory", width: 140, title: ( {t("column.memory")} ), sortable: true, render: (record) => , }, visibleMetricKeys.has("showDisk") && { accessor: "disk", width: 160, title: ( {t("column.disk")} ), sortable: true, render: (record) => ( ), }, visibleMetricKeys.has("showGpu") && { accessor: "gpu", width: 140, title: ( {t("column.gpu")} ), sortable: true, render: (record) => , }, visibleMetricKeys.has("showLoadAvg") && { accessor: "loadAvg", width: 100, title: ( {t("column.loadAvg")} ), sortable: true, render: (record) => ( {formatLoadAvg(record.loadAvg)} ), }, visibleMetricKeys.has("showNet") && { accessor: "netBytes", width: 100, title: ( {t("column.net")} ), sortable: true, render: (record) => ( {formatByteRate(record.netBytes)} ), }, visibleMetricKeys.has("showTemp") && { accessor: "temp", width: 80, title: ( {t("column.temp")} ), sortable: true, render: (record) => {formatTemp(record.temp, false)}, }, visibleMetricKeys.has("showBattery") && { accessor: "battery", width: 70, title: ( {t("column.battery")} ), render: (record) => {record.battery ? `${record.battery[0]}%` : "—"}, }, visibleMetricKeys.has("showServices") && { accessor: "services", width: 80, title: ( {t("column.services")} ), sortable: true, render: (record) => {record.services}, }, visibleMetricKeys.has("showUptime") && { accessor: "uptime", width: 90, title: ( {t("column.uptime")} ), sortable: true, render: (record) => {formatUptime(record.uptime)}, }, visibleMetricKeys.has("showAgent") && { accessor: "agentVersion", width: 90, title: ( {t("column.agent")} ), sortable: true, render: (record) => {record.agentVersion}, }, ]; return cols.filter(Boolean) as DataTableColumn[]; }, [t, size, visibleMetricKeys]); const { effectiveColumns, storeKey } = usePersistedTableLayout({ columns, columnAccessors, columnOrder: options.columnOrder, columnWidths: options.columnWidths, itemId, storeKeyPrefix: "beszel-system-table", onLayoutChange: persistLayout, }); const handleRowClick = useCallback( ({ record }: { record: SystemRowWithKey }) => { const integrationId = record._key.split(":")[0] ?? ""; openModal({ integrationId, systemId: record.id }, { title: record.name }); }, [openModal], ); if (isPending) { return (
); } const table = (
); return table; }