"use client"; import type { MantineSize } from "@mantine/core"; import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text, UnstyledButton } from "@mantine/core"; import { Activity, Battery, BatteryCharging, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer, Wifi, } from "lucide-react"; import { IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { formatBytes } from "@homarr/common"; import { useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import classes from "./component.module.css"; import type { WidgetComponentProps } from "../definition"; import type { BeszelSystemRow } from "../beszel/_shared/types"; import { 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 { getUsableWidgetQueryData } from "../common/query-state"; import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; import { isBeszelGridMetricVisible } from "./display"; interface SizeConfig { iconSize: number; fontSize: string; progressSize: "xs" | "sm"; labelMiw: number; valueMiw: number; rowHeight: number; cardPadding: number; badgeHeight: number; badgeSize: "xs" | "sm"; gap: number; } const defaultSizeConfig: SizeConfig = { iconSize: 10, fontSize: "xs", progressSize: "xs", labelMiw: 40, valueMiw: 30, rowHeight: 18, cardPadding: 4, badgeHeight: 20, badgeSize: "xs", gap: 2, }; const sizeThresholds: [number, SizeConfig][] = [ [0, defaultSizeConfig], [ 160, { iconSize: 12, fontSize: "xs", progressSize: "xs", labelMiw: 50, valueMiw: 34, rowHeight: 22, cardPadding: 6, badgeHeight: 22, badgeSize: "xs", gap: 6, }, ], [ 280, { iconSize: 14, fontSize: "xs", progressSize: "sm", labelMiw: 55, valueMiw: 38, rowHeight: 24, cardPadding: 8, badgeHeight: 26, badgeSize: "sm", gap: 8, }, ], ]; const getSizeConfig = (cellWidth: number, cellHeight: number): SizeConfig => { const basis = Math.min(cellWidth, cellHeight); let config = defaultSizeConfig; for (const [threshold, cfg] of sizeThresholds) { if (basis >= threshold) config = cfg; } return config; }; const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => { const available = cellHeight - size.cardPadding * 2 - size.badgeHeight - size.gap; return Math.max(1, Math.floor(available / size.rowHeight)); }; const MIN_CELL_WIDTH = 140; const MIN_CELL_HEIGHT = 80; const ADVANCED_MIN_CELL_WIDTH = 320; const ADVANCED_MIN_CELL_HEIGHT = 420; const getColCount = (width: number, height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => { if (itemCount <= 1) return 1; const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth))); const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT); const idealCols = Math.round(Math.sqrt(itemCount * aspectRatio)); return Math.min(maxCols, Math.max(1, idealCols)); }; interface MetricRowProps { icon: React.ReactNode; label: string; value: string; progress?: { value: number; color: string }; size: SizeConfig; } const MetricValue = ({ value, progress, size }: Pick) => ( {value} {progress && ( )} ); const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => ( {icon} {label} ); interface SystemCardProps { system: BeszelSystemRow & { rowKey: string }; options: WidgetComponentProps<"beszelSystemGrid">["options"]; t: ReturnType>; size: SizeConfig; maxMetrics: number; itemRadius: MantineSize; integrationName: string; isAdvanced: boolean; onClick?: () => void; } const metricRenderers = [ { key: "showCpu", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } label={t("metric.cpu")} value={formatPercent(s.cpu)} progress={{ value: s.cpu, color: thresholdColor(s.cpu) }} size={sz} /> ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showCpu, advanced), }, { key: "showMemory", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } label={t("metric.memory")} value={formatPercent(s.memory)} progress={{ value: s.memory, color: thresholdColor(s.memory) }} size={sz} /> ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showMemory, advanced), }, { key: "showDisk", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( {t("metric.disk")} ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showDisk, advanced), }, { key: "showGpu", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } label={t("metric.gpu")} value={formatPercent(s.gpu)} progress={{ value: s.gpu, color: thresholdColor(s.gpu) }} size={sz} /> ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showGpu, advanced, s.gpu > 0), }, { key: "showLoadAvg", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } label={t("metric.loadAvg")} value={formatLoadAvg(s.loadAvg)} size={sz} /> ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showLoadAvg, advanced, s.loadAvg !== null), }, { key: "showNet", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } label={t("metric.net")} value={formatByteRate(s.netBytes)} size={sz} /> ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showNet, advanced), }, { key: "showTemp", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } label={t("metric.temp")} value={formatTemp(s.temp, false)} size={sz} /> ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showTemp, advanced, s.temp !== null), }, { key: "showBattery", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => { const batteryIcons = [Battery, BatteryCharging] as const; const Icon = batteryIcons[Math.min(1, s.battery?.[1] ?? 0)] ?? Battery; return ( } label={t("metric.battery")} value={`${s.battery?.[0] ?? 0}%`} size={sz} /> ); }, visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showBattery, advanced, s.battery !== null), }, { key: "showServices", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } label={t("metric.services")} value={String(s.services)} size={sz} /> ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showServices, advanced), }, { key: "showUptime", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } label={t("metric.uptime")} value={formatUptime(s.uptime)} size={sz} /> ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showUptime, advanced), }, { key: "showAgent", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( } label={t("metric.agent")} value={s.agentVersion} size={sz} /> ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => isBeszelGridMetricVisible(o.showAgent, advanced), }, ] as const; const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, integrationName, isAdvanced, onClick, }: SystemCardProps) => { const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options, isAdvanced)); const visibleMetrics = enabledMetrics.slice(0, maxMetrics); const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length; return ( {onClick && ( )} {system.name} {isAdvanced && ( 0 ? formatBytes(system.memoryTotal) : "—"} /> )} {visibleMetrics.map((m) => m.render(system, t, size))} {hiddenMetricCount > 0 && ( +{hiddenMetricCount} )} ); }; const SystemMetadata = ({ label, value }: { label: string; value: string }) => ( {label} {value} ); export default function BeszelSystemGridWidget({ options, integrationIds, isEditMode, width, height, displayMode, }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); const board = useRequiredBoard(); const { openModal } = useModalAction(BeszelSystemStatsModal); const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); const results = getUsableWidgetQueryData(systemsQuery) ?? []; const { isPending } = systemsQuery; const isAdvanced = displayMode === "advanced"; const integrationNames = new Map(results.map((result) => [result.integrationId, result.integrationName])); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); if (isPending) { return (
); } if (filteredSystems.length === 0) { return (
{t("empty.noSystems")}
); } const minimumCellWidth = isAdvanced ? ADVANCED_MIN_CELL_WIDTH : MIN_CELL_WIDTH; const cols = getColCount(width, height, filteredSystems.length, minimumCellWidth); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; const minimumCellHeight = isAdvanced ? ADVANCED_MIN_CELL_HEIGHT : MIN_CELL_HEIGHT; const scrollEnabled = rawCellHeight < minimumCellHeight; const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight; const cellWidth = width / cols; const size = getSizeConfig(cellWidth, effectiveCellHeight); const maxMetrics = isAdvanced ? Number.POSITIVE_INFINITY : getMaxVisibleMetrics(effectiveCellHeight, size); return ( {filteredSystems.map((system) => { const integrationId = system.rowKey.split(":")[0] ?? ""; const integrationName = integrationNames.get(integrationId) ?? "—"; const handleClick = isEditMode ? undefined : () => openModal({ integrationId, systemId: system.id }, { title: system.name }); return ( ); })} ); }