From 6b1cc0fd6bac12169232b1fb2a7faf57cb9a6191 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 8 Jun 2026 15:51:30 +0200 Subject: [PATCH] refactor(beszel): migrate widgets to shared utilities and fix perf Update all four Beszel widgets to use extracted shared hooks, chart helpers, colors, and formatters. Fix chart data regressions: restore byte-to-GB conversion for memory/disk, use raw byte values for diskIO instead of incorrect multiplication, and use ns/nr fields for network instead of the optional b field. Add drag-and-drop performance fixes: disable pointer events and WebSocket subscriptions during edit mode to prevent re-renders while dragging widgets on the board. --- .../widgets/src/beszel-alerts/component.tsx | 237 ++++++++++--- .../src/beszel-system-grid/component.tsx | 320 +++++++++--------- .../widgets/src/beszel-system-grid/index.ts | 8 +- .../src/beszel-system-stats/component.tsx | 311 +++++------------ .../src/beszel-system-table/component.tsx | 45 +-- .../widgets/src/beszel-system-table/index.ts | 8 +- .../src/beszel-system-table/styles.css | 6 + 7 files changed, 469 insertions(+), 466 deletions(-) diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index e265a6286..6a900521a 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -1,26 +1,49 @@ "use client"; import { useMemo } from "react"; -import { Badge, Card, Group, Indicator, ScrollArea, Stack, Text } from "@mantine/core"; -import { IconAlertTriangle, IconBell, IconCheck } from "@tabler/icons-react"; +import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core"; +import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; +import type { LucideIcon } from "lucide-react"; +import { + Activity, + Battery, + Cpu, + HardDrive, + MemoryStick, + Monitor, + Network, + Server, + Thermometer, +} from "lucide-react"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -dayjs.extend(relativeTime); - -const alertColorMap: Record = { - triggered: "red", - ok: "green", +const alertIconMap: Record = { + CPU: Cpu, + Memory: MemoryStick, + Disk: HardDrive, + Bandwidth: Network, + Temperature: Thermometer, + "Load Average": Activity, + LoadAvg1: Activity, + LoadAvg5: Activity, + LoadAvg15: Activity, + Status: Server, + GPU: Monitor, + Battery: Battery, }; +dayjs.extend(relativeTime); + export default function BeszelAlertsWidget({ options, integrationIds, + isEditMode, }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({ @@ -33,6 +56,7 @@ export default function BeszelAlertsWidget({ clientApi.widget.beszel.subscribeAlerts.useSubscription( { integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }, { + enabled: !isEditMode, onData(data) { utils.widget.beszel.getAlerts.setData( { integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }, @@ -69,59 +93,178 @@ export default function BeszelAlertsWidget({ return all.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()).slice(0, options.maxHistoryItems); }, [results, options.maxHistoryItems]); + const triggeredAlerts = alerts.filter((a) => a.triggered); + const okAlerts = alerts.filter((a) => !a.triggered); + return ( - - + + {alerts.length === 0 && ( - - + + + + {t("empty")} - + )} - {alerts.map((alert) => { - const state = alert.triggered ? "triggered" : "ok"; - const systemName = systemNameMap[alert.system] ?? alert.system; - return ( - - - - - {alert.triggered ? : } - - - {alert.name} > {alert.value} - {systemName} - - - - {t(`status.${state}`)} - - - - ); - })} + {triggeredAlerts.length > 0 && ( + + + + + {t("status.triggered")} ({triggeredAlerts.length}) + + + {triggeredAlerts.map((alert) => ( + + ))} + + )} + + {triggeredAlerts.length > 0 && okAlerts.length > 0 && } + + {okAlerts.length > 0 && ( + + + + + {t("status.ok")} ({okAlerts.length}) + + + {okAlerts.map((alert) => ( + + ))} + + )} {options.showHistory && history.length > 0 && ( <> - {t("history")} - {history.map((entry) => { - const systemName = systemNameMap[entry.system] ?? entry.system; - return ( - - - - {entry.name} - {systemName} + + + + + + {t("history")} + + + {history.map((entry) => { + const systemName = systemNameMap[entry.system] ?? entry.system; + const isResolved = !!entry.resolved; + const HistoryIcon = alertIconMap[entry.name] ?? Server; + return ( + + + + + + + {entry.name} + + + {systemName} + + + + + + {isResolved ? t("resolved") : t("status.triggered")} + + + {dayjs(entry.created).fromNow()} + - {dayjs(entry.created).fromNow()} - - ); - })} + ); + })} + )} ); } + +interface AlertRowProps { + name: string; + value: number; + min: number; + systemName: string; + triggered: boolean; +} + +const unitSuffixMap: Record = { + CPU: "%", + Memory: "%", + Disk: "%", + GPU: "%", + Battery: "%", + Temperature: "°", + Bandwidth: " MB/s", + "Load Average": "", + LoadAvg1: "", + LoadAvg5: "", + LoadAvg15: "", + Status: "", +}; + +function formatAlertDescription(name: string, value: number, min: number): string { + const suffix = unitSuffixMap[name] ?? ""; + if (name === "Status") return `down for ${min} min`; + return `exceeds ${value}${suffix} over ${min} min`; +} + +function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) { + const Icon = alertIconMap[name] ?? Server; + return ( + + + + + + {systemName} + + · + + {name} + + + + {formatAlertDescription(name, value, min)} + + + + ); +} diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index ccf82da9a..f9a081bbd 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -1,7 +1,6 @@ "use client"; -import { useMemo } from "react"; -import { Badge, Card, Group, Progress, ScrollArea, SimpleGrid, Text, Stack } from "@mantine/core"; +import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core"; import { Activity, Battery, @@ -21,185 +20,192 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import type { BeszelSystemRow } from "../beszel/_shared/types"; -import { statusColorMap, thresholdColor, loadAvgColor } from "../beszel/_shared/colors"; -import { formatBytes, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; +import { statusColorMap, thresholdColor } from "../beszel/_shared/colors"; +import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; +import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; -const ICON_SIZE = 14; +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 sizeThresholds: [number, SizeConfig][] = [ + [0, { iconSize: 10, fontSize: "10px", progressSize: "xs", labelMiw: 40, valueMiw: 30, rowHeight: 18, cardPadding: 4, badgeHeight: 20, badgeSize: "xs", gap: 2 }], + [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 = sizeThresholds[0]![1]; + 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 = 120; +const MIN_CELL_HEIGHT = 80; + +const getColCount = (width: number, itemCount: number): number => { + if (itemCount <= 1) return 1; + const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH))); + + let best = 1; + for (let c = 1; c <= maxCols; c++) { + const emptyCells = (c - (itemCount % c)) % c; + const bestEmpty = (best - (itemCount % best)) % best; + if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) { + best = c; + } + } + return best; +}; interface MetricRowProps { icon: React.ReactNode; label: string; value: string; progress?: { value: number; color: string }; + size: SizeConfig; } -const MetricRow = ({ icon, label, value, progress }: MetricRowProps) => ( - - {icon} - - {label} - - {progress ? ( - - - {value} - - - - ) : ( - - {value} - - )} +const MetricValue = ({ value, progress, size }: Pick) => ( + + {value} + {progress && } ); -const SystemCard = ({ system, options, t }: { system: BeszelSystemRow & { _key: string }; options: WidgetComponentProps<"beszelSystemGrid">["options"]; t: ReturnType> }) => ( - - - - {system.name} - - - - - {options.showCpu && ( - } - label={t("metric.cpu")} - value={formatPercent(system.cpu)} - progress={{ value: system.cpu, color: thresholdColor(system.cpu) }} - /> - )} - {options.showMemory && ( - } - label={t("metric.memory")} - value={formatPercent(system.memory)} - progress={{ value: system.memory, color: thresholdColor(system.memory) }} - /> - )} - {options.showDisk && ( - } - label={t("metric.disk")} - value={formatPercent(system.disk)} - progress={{ value: system.disk, color: thresholdColor(system.disk) }} - /> - )} - {options.showGpu && system.gpu > 0 && ( - } - label={t("metric.gpu")} - value={formatPercent(system.gpu)} - progress={{ value: system.gpu, color: thresholdColor(system.gpu) }} - /> - )} - {options.showLoadAvg && system.loadAvg && ( - } - label={t("metric.loadAvg")} - value={formatLoadAvg(system.loadAvg)} - /> - )} - {options.showNet && ( - } - label={t("metric.net")} - value={formatBytes(system.netBytes)} - /> - )} - {options.showTemp && system.temp !== null && ( - } - label={t("metric.temp")} - value={formatTemp(system.temp, false)} - /> - )} - {options.showBattery && system.battery && ( - 0 ? : } - label={t("metric.battery")} - value={`${system.battery[0]}%`} - /> - )} - {options.showServices && ( - } - label={t("metric.services")} - value={String(system.services)} - /> - )} - {options.showUptime && ( - } - label={t("metric.uptime")} - value={formatUptime(system.uptime)} - /> - )} - {options.showAgent && ( - } - label={t("metric.agent")} - value={system.agentVersion} - /> - )} - - +const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => ( + + {icon} + {label} + + ); +interface SystemCardProps { + system: BeszelSystemRow & { _key: string }; + options: WidgetComponentProps<"beszelSystemGrid">["options"]; + t: ReturnType>; + size: SizeConfig; + maxMetrics: number; +} + +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"]) => o.showCpu }, + { 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"]) => o.showMemory }, + { key: "showDisk", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } label={t("metric.disk")} value={formatPercent(s.disk)} progress={{ value: s.disk, color: thresholdColor(s.disk) }} size={sz} /> + ), visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk }, + { 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"]) => o.showGpu && 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"]) => o.showLoadAvg && 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"]) => o.showNet }, + { 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"]) => o.showTemp && 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)]!; + return } label={t("metric.battery")} value={`${s.battery?.[0] ?? 0}%`} size={sz} />; + }, visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && 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"]) => o.showServices }, + { 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"]) => o.showUptime }, + { 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"]) => o.showAgent }, +] as const; + +const SystemCard = ({ system, options, t, size, maxMetrics }: SystemCardProps) => { + const visibleMetrics = metricRenderers + .filter((m) => m.visible(system, options)) + .slice(0, maxMetrics); + + return ( + + + + {system.name} + + + + + {visibleMetrics.map((m) => m.render(system, t, size))} + + + ); +}; + export default function BeszelSystemGridWidget({ options, integrationIds, + isEditMode, width, + height, }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds }); - const utils = clientApi.useUtils(); - clientApi.widget.beszel.subscribeSystems.useSubscription( - { integrationIds }, - { - onData(data) { - utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => { - if (!prev) return prev; - return prev.map((r) => - r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r, - ); - }); - }, - }, - ); + useBeszelSystemsSubscription(integrationIds, !isEditMode); + const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); - const allSystems = useMemo( - () => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))), - [results], - ); - - const filteredSystems = useMemo(() => { - if (options.statusFilter === "all") return allSystems; - return allSystems.filter((s) => s.status === options.statusFilter); - }, [allSystems, options.statusFilter]); - - const getColCount = (w: number) => { - if (w > 1200) return 4; - if (w > 800) return 3; - if (w > 500) return 2; - return 1; - }; + const cols = getColCount(width, filteredSystems.length); + const rows = Math.ceil(filteredSystems.length / cols) || 1; + const rawCellHeight = height / rows; + const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT; + const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight; + const cellWidth = width / cols; + const size = getSizeConfig(cellWidth, effectiveCellHeight); + const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); return ( - - - {filteredSystems.map((system) => ( - - ))} - - + + {filteredSystems.map((system) => ( + + ))} + ); } diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts index f5fab8a5a..25d5e7805 100644 --- a/packages/widgets/src/beszel-system-grid/index.ts +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -1,15 +1,9 @@ import { IconLayoutGrid } from "@tabler/icons-react"; import { createWidgetDefinition } from "../definition"; +import { statusOptions } from "../beszel/_shared/options"; import { optionsBuilder } from "../options"; -const statusOptions = [ - { value: "all", label: "All Systems" }, - { value: "up", label: "Up" }, - { value: "down", label: "Down" }, - { value: "paused", label: "Paused" }, -]; - export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { icon: IconLayoutGrid, supportedIntegrations: ["beszel"], diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 67ed9db40..2e34f2be2 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -1,54 +1,41 @@ "use client"; -import { useMemo, useState } from "react"; -import { Group, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; -import { AreaChart } from "@mantine/charts"; -import dayjs from "dayjs"; +import { useCallback, useMemo, useState } from "react"; +import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { containerColors } from "../beszel/_shared/colors"; +import { + BeszelAreaChart, + ChartPanel, + useContainerNames, + useDockerChartData, + useSystemChartData, +} from "../beszel/_shared/chart"; +import { + chartAxisFormatters, + formatByteRate, + formatGB, + formatPercent, + formatStorageBytes, +} from "../beszel/_shared/format"; +import { makeTooltipProps } from "../beszel/_shared/tooltip"; -const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm"); -const formatBytes = (bytes: number) => { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; -}; -const formatBytesPerSec = (bytes: number) => `${formatBytes(bytes)}/s`; -const formatPercent = (v: number) => `${v.toFixed(1)}%`; -const formatGB = (v: number) => `${v.toFixed(2)} GB`; -const formatMB = (v: number) => `${v.toFixed(1)} MB`; -const formatMBs = (v: number) => `${v.toFixed(2)} MB/s`; +const tooltipPercent = makeTooltipProps(formatPercent); +const tooltipGB = makeTooltipProps(formatGB, true); +const tooltipRate = makeTooltipProps(formatByteRate, true); +const tooltipPercentTotal = makeTooltipProps(formatPercent, true); +const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true); -const compactTooltipProps = { - wrapperStyle: { zIndex: 1000, pointerEvents: "none" as const }, - contentStyle: { padding: "4px 8px", fontSize: 11, lineHeight: 1.3 }, - labelStyle: { fontSize: 11, marginBottom: 2 }, - itemStyle: { padding: 0, fontSize: 11 }, -}; - -interface ChartPanelProps { - title: string; - subtitle?: string; - children: React.ReactNode; -} - -const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => ( - - - {title} - {subtitle && {subtitle}} - - {children} - -); +const CHART_HEIGHT = 180; export default function BeszelSystemStatsWidget({ options, integrationIds, + isEditMode, width, }: WidgetComponentProps<"beszelSystemStats">) { const t = useScopedI18n("widget.beszelSystemStats"); @@ -61,111 +48,42 @@ export default function BeszelSystemStatsWidget({ const [selectedSystem, setSelectedSystem] = useState(systems[0]?.value ?? null); + const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; + const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({ integrationIds, systemId: selectedSystem ?? systems[0]?.value ?? "", timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d", - includeDocker: options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork, + includeDocker: showDocker, }); - const chartHeight = 180; - const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; + const BYTES_TO_GB = 1024 * 1024 * 1024; - const cpuData = useMemo(() => { - if (!statsResult?.systemStats) return []; - return [...statsResult.systemStats].reverse().map((r) => ({ - time: formatTime(r.created), - [t("chart.cpu.series")]: r.stats.cpu, - })); - }, [statsResult, t]); + const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]); + const memoryMap = useCallback((s: { mu: number; mb: number }) => ({ + [t("chart.memory.series")]: s.mu / BYTES_TO_GB, + [t("chart.memory.cache")]: (s.mb ?? 0) / BYTES_TO_GB, + }), [t]); + const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du / BYTES_TO_GB }), [t]); + const diskIOMap = useCallback((s: { dr?: number; dw?: number }) => ({ + [t("chart.diskIO.read")]: s.dr ?? 0, + [t("chart.diskIO.write")]: s.dw ?? 0, + }), [t]); + const networkMap = useCallback((s: { ns: number; nr: number }) => ({ + [t("chart.network.sent")]: s.ns, + [t("chart.network.recv")]: s.nr, + }), [t]); - const memoryData = useMemo(() => { - if (!statsResult?.systemStats) return []; - return [...statsResult.systemStats].reverse().map((r) => ({ - time: formatTime(r.created), - [t("chart.memory.series")]: r.stats.mu / (1024 * 1024 * 1024), - })); - }, [statsResult, t]); + const cpuData = useSystemChartData(statsResult?.systemStats, cpuMap); + const memoryData = useSystemChartData(statsResult?.systemStats, memoryMap); + const diskData = useSystemChartData(statsResult?.systemStats, diskMap); + const diskIOData = useSystemChartData(statsResult?.systemStats, diskIOMap); + const networkData = useSystemChartData(statsResult?.systemStats, networkMap); - const diskData = useMemo(() => { - if (!statsResult?.systemStats) return []; - return [...statsResult.systemStats].reverse().map((r) => ({ - time: formatTime(r.created), - [t("chart.disk.series")]: r.stats.du / (1024 * 1024 * 1024), - })); - }, [statsResult, t]); - - const diskIOData = useMemo(() => { - if (!statsResult?.systemStats) return []; - return [...statsResult.systemStats].reverse().map((r) => ({ - time: formatTime(r.created), - [t("chart.diskIO.read")]: r.stats.dr / (1024 * 1024), - [t("chart.diskIO.write")]: r.stats.dw / (1024 * 1024), - })); - }, [statsResult, t]); - - const networkData = useMemo(() => { - if (!statsResult?.systemStats) return []; - return [...statsResult.systemStats].reverse().map((r) => ({ - time: formatTime(r.created), - [t("chart.network.sent")]: r.stats.ns, - [t("chart.network.recv")]: r.stats.nr, - })); - }, [statsResult, t]); - - const containerNames = useMemo(() => { - if (!statsResult?.containerStats?.length) return []; - const names = new Set(); - for (const record of statsResult.containerStats) { - for (const c of record.stats) { - names.add(c.n); - } - } - return [...names].slice(0, 15); - }, [statsResult]); - - const dockerCpuData = useMemo(() => { - if (!statsResult?.containerStats?.length) return []; - return [...statsResult.containerStats].reverse().map((record) => { - const point: Record = { time: formatTime(record.created) }; - for (const name of containerNames) { - const container = record.stats.find((c) => c.n === name); - point[name] = container?.c ?? 0; - } - return point; - }); - }, [statsResult, containerNames]); - - const dockerMemData = useMemo(() => { - if (!statsResult?.containerStats?.length) return []; - return [...statsResult.containerStats].reverse().map((record) => { - const point: Record = { time: formatTime(record.created) }; - for (const name of containerNames) { - const container = record.stats.find((c) => c.n === name); - point[name] = (container?.m ?? 0) / (1024 * 1024); - } - return point; - }); - }, [statsResult, containerNames]); - - const dockerNetData = useMemo(() => { - if (!statsResult?.containerStats?.length) return []; - return [...statsResult.containerStats].reverse().map((record) => { - const point: Record = { time: formatTime(record.created) }; - for (const name of containerNames) { - const container = record.stats.find((c) => c.n === name); - const netTotal = (container?.ns ?? 0) + (container?.nr ?? 0); - point[name] = netTotal / 1024; - } - return point; - }); - }, [statsResult, containerNames]); - - const containerColors = [ - "blue.6", "teal.6", "orange.6", "grape.6", "cyan.6", - "pink.6", "lime.6", "violet.6", "indigo.6", "yellow.6", - "red.6", "green.6", "blue.4", "teal.4", "orange.4", - ]; + const containerNames = useContainerNames(statsResult?.containerStats); + const dockerCpuData = useDockerChartData(statsResult?.containerStats, containerNames, "cpu"); + const dockerMemData = useDockerChartData(statsResult?.containerStats, containerNames, "memory"); + const dockerNetData = useDockerChartData(statsResult?.containerStats, containerNames, "network"); const containerSeries = containerNames.map((name, i) => ({ name, @@ -175,7 +93,7 @@ export default function BeszelSystemStatsWidget({ const cols = width > 600 ? 2 : 1; return ( - +