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:
1 parent
3131687229
commit
6b1cc0fd6b
7 files changed
+469
-466
No files matched your search
@@ -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} > {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;
|
||||
|
||||
Reference in new issue
Block a user