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.
This commit is contained in:
Thomas Camlong committed 2026-06-08 15:51:30 +02:00
1 parent 3131687229
commit 6b1cc0fd6b
7 files changed
+469 -466

No files matched your search

+190 -47
View File
@@ -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<string, string> = {
triggered: "red",
ok: "green",
const alertIconMap: Record<string, LucideIcon> = {
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 (
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
{alerts.length === 0 && (
<Group gap="xs" justify="center" py="md">
<IconBell size={20} opacity={0.5} />
<Stack align="center" justify="center" py="xl" gap="xs">
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconBellOff size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed">{t("empty")}</Text>
</Group>
</Stack>
)}
{alerts.map((alert) => {
const state = alert.triggered ? "triggered" : "ok";
const systemName = systemNameMap[alert.system] ?? alert.system;
return (
<Card key={alert._key} padding="xs" radius="sm" withBorder>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Indicator color={alertColorMap[state]} size={8} processing={alert.triggered}>
{alert.triggered ? <IconAlertTriangle size={16} /> : <IconCheck size={16} />}
</Indicator>
<Stack gap={0}>
<Text size="xs" fw={500}>{alert.name} &gt; {alert.value}</Text>
<Text size="xs" c="dimmed">{systemName}</Text>
</Stack>
</Group>
<Badge size="xs" variant="light" color={alertColorMap[state]}>
{t(`status.${state}`)}
</Badge>
</Group>
</Card>
);
})}
{triggeredAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconFlame size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} tt="uppercase" c="red">
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
</Group>
{triggeredAlerts.map((alert) => (
<AlertRow
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered
/>
))}
</Stack>
)}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
{okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
{okAlerts.map((alert) => (
<AlertRow
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered={false}
/>
))}
</Stack>
)}
{options.showHistory && history.length > 0 && (
<>
<Text size="xs" fw={600} c="dimmed" mt="xs">{t("history")}</Text>
{history.map((entry) => {
const systemName = systemNameMap[entry.system] ?? entry.system;
return (
<Card key={entry._key} padding="xs" radius="sm" withBorder>
<Group justify="space-between" wrap="nowrap">
<Stack gap={0}>
<Text size="xs" fw={500}>{entry.name}</Text>
<Text size="xs" c="dimmed">{systemName}</Text>
<Divider />
<Stack gap={6}>
<Group gap={6}>
<IconHistory size={14} opacity={0.5} />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("history")}
</Text>
</Group>
{history.map((entry) => {
const systemName = systemNameMap[entry.system] ?? entry.system;
const isResolved = !!entry.resolved;
const HistoryIcon = alertIconMap[entry.name] ?? Server;
return (
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={3}
h={24}
style={{ borderRadius: 2, flexShrink: 0 }}
bg={isResolved ? "green.6" : "red.6"}
/>
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={500} truncate>
{entry.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{systemName}
</Text>
</Stack>
</Group>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
{isResolved ? t("resolved") : t("status.triggered")}
</Badge>
<Text size="10px" c="dimmed">
{dayjs(entry.created).fromNow()}
</Text>
</Stack>
<Text size="xs" c="dimmed">{dayjs(entry.created).fromNow()}</Text>
</Group>
</Card>
);
})}
);
})}
</Stack>
</>
)}
</Stack>
</ScrollArea>
);
}
interface AlertRowProps {
name: string;
value: number;
min: number;
systemName: string;
triggered: boolean;
}
const unitSuffixMap: Record<string, string> = {
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 (
<Group
wrap="nowrap"
gap="xs"
py={4}
pr={8}
pl={12}
style={{
borderRadius: "var(--mantine-radius-sm)",
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
backgroundColor: triggered
? "var(--mantine-color-red-light)"
: "var(--mantine-color-default-hover)",
}}
>
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{systemName}
</Text>
<Text size="xs" c="dimmed">·</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
{formatAlertDescription(name, value, min)}
</Text>
</Stack>
</Group>
);
}
@@ -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) => (
<Group gap="xs" wrap="nowrap" py={2}>
{icon}
<Text size="xs" c="dimmed" miw={55}>
{label}
</Text>
{progress ? (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
<Text size="xs" fw={500} miw={38} ta="right">
{value}
</Text>
<Progress value={progress.value} color={progress.color} size="sm" style={{ flex: 1 }} />
</Group>
) : (
<Text size="xs" fw={500}>
{value}
</Text>
)}
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">{value}</Text>
{progress && <Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />}
</Group>
);
const SystemCard = ({ system, options, t }: { system: BeszelSystemRow & { _key: string }; options: WidgetComponentProps<"beszelSystemGrid">["options"]; t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">> }) => (
<Card padding={6} radius="sm" withBorder>
<Group gap="xs" mb={4}>
<Badge
size="xs"
variant="dot"
color={statusColorMap[system.status]}
styles={{ root: { textTransform: "none" } }}
>
{system.name}
</Badge>
</Group>
<Stack gap={0}>
{options.showCpu && (
<MetricRow
icon={<Cpu size={ICON_SIZE} />}
label={t("metric.cpu")}
value={formatPercent(system.cpu)}
progress={{ value: system.cpu, color: thresholdColor(system.cpu) }}
/>
)}
{options.showMemory && (
<MetricRow
icon={<MemoryStick size={ICON_SIZE} />}
label={t("metric.memory")}
value={formatPercent(system.memory)}
progress={{ value: system.memory, color: thresholdColor(system.memory) }}
/>
)}
{options.showDisk && (
<MetricRow
icon={<HardDrive size={ICON_SIZE} />}
label={t("metric.disk")}
value={formatPercent(system.disk)}
progress={{ value: system.disk, color: thresholdColor(system.disk) }}
/>
)}
{options.showGpu && system.gpu > 0 && (
<MetricRow
icon={<Monitor size={ICON_SIZE} />}
label={t("metric.gpu")}
value={formatPercent(system.gpu)}
progress={{ value: system.gpu, color: thresholdColor(system.gpu) }}
/>
)}
{options.showLoadAvg && system.loadAvg && (
<MetricRow
icon={<Activity size={ICON_SIZE} />}
label={t("metric.loadAvg")}
value={formatLoadAvg(system.loadAvg)}
/>
)}
{options.showNet && (
<MetricRow
icon={<Network size={ICON_SIZE} />}
label={t("metric.net")}
value={formatBytes(system.netBytes)}
/>
)}
{options.showTemp && system.temp !== null && (
<MetricRow
icon={<Thermometer size={ICON_SIZE} />}
label={t("metric.temp")}
value={formatTemp(system.temp, false)}
/>
)}
{options.showBattery && system.battery && (
<MetricRow
icon={system.battery[1] > 0 ? <BatteryCharging size={ICON_SIZE} /> : <Battery size={ICON_SIZE} />}
label={t("metric.battery")}
value={`${system.battery[0]}%`}
/>
)}
{options.showServices && (
<MetricRow
icon={<Server size={ICON_SIZE} />}
label={t("metric.services")}
value={String(system.services)}
/>
)}
{options.showUptime && (
<MetricRow
icon={<Activity size={ICON_SIZE} />}
label={t("metric.uptime")}
value={formatUptime(system.uptime)}
/>
)}
{options.showAgent && (
<MetricRow
icon={<Wifi size={ICON_SIZE} />}
label={t("metric.agent")}
value={system.agentVersion}
/>
)}
</Stack>
</Card>
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
{icon}
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>{label}</Text>
<MetricValue value={value} progress={progress} size={size} />
</Group>
);
interface SystemCardProps {
system: BeszelSystemRow & { _key: string };
options: WidgetComponentProps<"beszelSystemGrid">["options"];
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
size: SizeConfig;
maxMetrics: number;
}
const metricRenderers = [
{ key: "showCpu", render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow key="cpu" icon={<Cpu size={sz.iconSize} />} 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) => (
<MetricRow key="mem" icon={<MemoryStick size={sz.iconSize} />} 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) => (
<MetricRow key="disk" icon={<HardDrive size={sz.iconSize} />} 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) => (
<MetricRow key="gpu" icon={<Monitor size={sz.iconSize} />} 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) => (
<MetricRow key="la" icon={<Activity size={sz.iconSize} />} 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) => (
<MetricRow key="net" icon={<Network size={sz.iconSize} />} 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) => (
<MetricRow key="temp" icon={<Thermometer size={sz.iconSize} />} 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 <MetricRow key="bat" icon={<Icon size={sz.iconSize} />} 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) => (
<MetricRow key="svc" icon={<Server size={sz.iconSize} />} 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) => (
<MetricRow key="up" icon={<Activity size={sz.iconSize} />} 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) => (
<MetricRow key="agent" icon={<Wifi size={sz.iconSize} />} 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 (
<Card padding={size.cardPadding} radius="sm" withBorder h="100%" style={{ overflow: "hidden", display: "flex", flexDirection: "column" as const }}>
<Group gap="xs" mb={2}>
<Badge
size={size.badgeSize}
variant="dot"
color={statusColorMap[system.status]}
styles={{ root: { textTransform: "none" } }}
>
{system.name}
</Badge>
</Group>
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
</Stack>
</Card>
);
};
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 (
<ScrollArea h="100%">
<SimpleGrid cols={getColCount(width)} spacing="xs" p={0}>
{filteredSystems.map((system) => (
<SystemCard key={system._key} system={system} options={options} t={t} />
))}
</SimpleGrid>
</ScrollArea>
<Box
h="100%"
style={{
pointerEvents: isEditMode ? "none" : undefined,
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
>
{filteredSystems.map((system) => (
<SystemCard key={system._key} system={system} options={options} t={t} size={size} maxMetrics={maxMetrics} />
))}
</Box>
);
}
@@ -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"],
@@ -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) => (
<Stack gap={4}>
<Group gap="xs">
<Text size="sm" fw={600}>{title}</Text>
{subtitle && <Text size="xs" c="dimmed">{subtitle}</Text>}
</Group>
{children}
</Stack>
);
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<string | null>(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<string>();
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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 (
<ScrollArea h="100%">
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="md" p="sm">
<Select
size="xs"
@@ -189,79 +107,60 @@ export default function BeszelSystemStatsWidget({
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<ChartPanel title={t("chart.cpu.title")} subtitle={t("chart.cpu.subtitle")}>
<AreaChart
h={chartHeight}
<BeszelAreaChart
h={CHART_HEIGHT}
data={cpuData}
dataKey="time"
series={[{ name: t("chart.cpu.series"), color: "teal.6" }]}
curveType="monotone"
withDots={false}
fillOpacity={0.4}
yAxisProps={{ domain: [0, 100], tickFormatter: (v: number) => `${v}%` }}
valueFormatter={formatPercent}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
yAxisFormatter={chartAxisFormatters.percent}
yAxisDomain={[0, "auto"]}
tooltipProps={tooltipPercent}
/>
</ChartPanel>
)}
{options.showMemory && memoryData.length > 0 && (
<ChartPanel title={t("chart.memory.title")} subtitle={t("chart.memory.subtitle")}>
<AreaChart
h={chartHeight}
<BeszelAreaChart
h={CHART_HEIGHT}
data={memoryData}
dataKey="time"
series={[{ name: t("chart.memory.series"), color: "teal.6" }]}
curveType="monotone"
withDots={false}
series={[
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
]}
fillOpacity={0.4}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(0)} GB` }}
valueFormatter={formatGB}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
withLegend
/>
</ChartPanel>
)}
{options.showDisk && diskData.length > 0 && (
<ChartPanel title={t("chart.disk.title")} subtitle={t("chart.disk.subtitle")}>
<AreaChart
h={chartHeight}
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskData}
dataKey="time"
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
curveType="monotone"
withDots={false}
fillOpacity={0.4}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(0)} GB` }}
valueFormatter={formatGB}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
/>
</ChartPanel>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<ChartPanel title={t("chart.diskIO.title")} subtitle={t("chart.diskIO.subtitle")}>
<AreaChart
h={chartHeight}
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskIOData}
dataKey="time"
series={[
{ name: t("chart.diskIO.read"), color: "orange.6" },
{ name: t("chart.diskIO.write"), color: "blue.6" },
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
]}
curveType="monotone"
withDots={false}
fillOpacity={0.3}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(1)} MB/s` }}
valueFormatter={formatMBs}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
@@ -269,22 +168,16 @@ export default function BeszelSystemStatsWidget({
{options.showNetwork && networkData.length > 0 && (
<ChartPanel title={t("chart.network.title")} subtitle={t("chart.network.subtitle")}>
<AreaChart
h={chartHeight}
<BeszelAreaChart
h={CHART_HEIGHT}
data={networkData}
dataKey="time"
series={[
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
]}
curveType="monotone"
withDots={false}
fillOpacity={0.3}
yAxisProps={{ tickFormatter: (v: number) => formatBytesPerSec(v) }}
valueFormatter={(v: number) => formatBytesPerSec(v)}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
@@ -294,60 +187,42 @@ export default function BeszelSystemStatsWidget({
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<ChartPanel title={t("chart.dockerCpu.title")} subtitle={t("chart.dockerCpu.subtitle")}>
<AreaChart
h={chartHeight}
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerCpuData}
dataKey="time"
type="stacked"
series={containerSeries}
curveType="monotone"
withDots={false}
fillOpacity={0.6}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(2)}%` }}
valueFormatter={formatPercent}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
yAxisFormatter={chartAxisFormatters.percentPrecise}
tooltipProps={tooltipPercentTotal}
/>
</ChartPanel>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<ChartPanel title={t("chart.dockerMemory.title")} subtitle={t("chart.dockerMemory.subtitle")}>
<AreaChart
h={chartHeight}
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerMemData}
dataKey="time"
type="stacked"
series={containerSeries}
curveType="monotone"
withDots={false}
fillOpacity={0.6}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(0)} MB` }}
valueFormatter={formatMB}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
yAxisFormatter={chartAxisFormatters.bytes}
tooltipProps={tooltipBytesTotal}
/>
</ChartPanel>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<ChartPanel title={t("chart.dockerNetwork.title")} subtitle={t("chart.dockerNetwork.subtitle")}>
<AreaChart
h={chartHeight}
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerNetData}
dataKey="time"
type="stacked"
series={containerSeries}
curveType="monotone"
withDots={false}
fillOpacity={0.6}
yAxisProps={{ tickFormatter: (v: number) => `${v.toFixed(0)} KB/s` }}
valueFormatter={(v: number) => `${v.toFixed(1)} KB/s`}
tooltipProps={compactTooltipProps}
withXAxis
withYAxis
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
/>
</ChartPanel>
)}
@@ -24,8 +24,9 @@ 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 { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -49,36 +50,15 @@ const getSizeConfig = (width: number): SizeConfig => {
export default function BeszelSystemTableWidget({
options,
integrationIds,
isEditMode,
width,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const utils = clientApi.useUtils();
const size = getSizeConfig(width);
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,
);
});
},
},
);
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]);
useBeszelSystemsSubscription(integrationIds, !isEditMode);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
columnAccessor: options.sortBy,
@@ -99,7 +79,7 @@ export default function BeszelSystemTableWidget({
const PercentCell = ({ value }: { value: number }) => (
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
@@ -118,7 +98,7 @@ export default function BeszelSystemTableWidget({
),
sortable: true,
render: (record) => (
<Group gap={4} wrap="nowrap">
<Group gap={8} wrap="nowrap">
<Indicator color={statusColorMap[record.status]} size={7} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
@@ -180,7 +160,7 @@ export default function BeszelSystemTableWidget({
),
sortable: true,
render: (record) => (
<Group gap={4} wrap="nowrap">
<Group gap={8} wrap="nowrap">
<Indicator
color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"}
size={7}
@@ -198,7 +178,11 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatBytes(record.netBytes)}</Text>,
render: (record) => (
<Text size={size.fontSize} style={{ whiteSpace: "nowrap" }}>
{formatByteRate(record.netBytes)}
</Text>
),
},
options.showTemp && {
accessor: "temp",
@@ -263,6 +247,7 @@ export default function BeszelSystemTableWidget({
return (
<DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
@@ -1,15 +1,9 @@
import { IconTable } 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" },
];
const sortOptions = [
{ value: "name", label: "System" },
{ value: "cpu", label: "CPU" },
@@ -8,6 +8,12 @@
text-overflow: ellipsis;
}
.beszel-table td .mantine-Text-root,
.beszel-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.beszel-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;