532 lines
17 KiB
TypeScript
532 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import type { MantineSize } from "@mantine/core";
|
|
import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text, UnstyledButton } from "@mantine/core";
|
|
import {
|
|
Activity,
|
|
Battery,
|
|
BatteryCharging,
|
|
Cpu,
|
|
HardDrive,
|
|
MemoryStick,
|
|
Monitor,
|
|
Network,
|
|
Server,
|
|
Thermometer,
|
|
Wifi,
|
|
} from "lucide-react";
|
|
import { IconServerOff } from "@tabler/icons-react";
|
|
|
|
import { clientApi } from "@homarr/api/client";
|
|
import { useRequiredBoard } from "@homarr/boards/context";
|
|
import { formatBytes } from "@homarr/common";
|
|
import { useModalAction } from "@homarr/modals";
|
|
import { useScopedI18n } from "@homarr/translation/client";
|
|
|
|
import classes from "./component.module.css";
|
|
|
|
import type { WidgetComponentProps } from "../definition";
|
|
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
|
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
|
import {
|
|
formatByteRate,
|
|
formatLoadAvg,
|
|
formatPercent,
|
|
formatTemp,
|
|
formatUptime,
|
|
getProgressTrackSize,
|
|
} from "../beszel/_shared/format";
|
|
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
|
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
|
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
|
import { isBeszelGridMetricVisible } from "./display";
|
|
|
|
interface SizeConfig {
|
|
iconSize: number;
|
|
fontSize: string;
|
|
progressSize: "xs" | "sm";
|
|
labelMiw: number;
|
|
valueMiw: number;
|
|
rowHeight: number;
|
|
cardPadding: number;
|
|
badgeHeight: number;
|
|
badgeSize: "xs" | "sm";
|
|
gap: number;
|
|
}
|
|
|
|
const defaultSizeConfig: SizeConfig = {
|
|
iconSize: 10,
|
|
fontSize: "xs",
|
|
progressSize: "xs",
|
|
labelMiw: 40,
|
|
valueMiw: 30,
|
|
rowHeight: 18,
|
|
cardPadding: 4,
|
|
badgeHeight: 20,
|
|
badgeSize: "xs",
|
|
gap: 2,
|
|
};
|
|
|
|
const sizeThresholds: [number, SizeConfig][] = [
|
|
[0, defaultSizeConfig],
|
|
[
|
|
160,
|
|
{
|
|
iconSize: 12,
|
|
fontSize: "xs",
|
|
progressSize: "xs",
|
|
labelMiw: 50,
|
|
valueMiw: 34,
|
|
rowHeight: 22,
|
|
cardPadding: 6,
|
|
badgeHeight: 22,
|
|
badgeSize: "xs",
|
|
gap: 6,
|
|
},
|
|
],
|
|
[
|
|
280,
|
|
{
|
|
iconSize: 14,
|
|
fontSize: "xs",
|
|
progressSize: "sm",
|
|
labelMiw: 55,
|
|
valueMiw: 38,
|
|
rowHeight: 24,
|
|
cardPadding: 8,
|
|
badgeHeight: 26,
|
|
badgeSize: "sm",
|
|
gap: 8,
|
|
},
|
|
],
|
|
];
|
|
|
|
const getSizeConfig = (cellWidth: number, cellHeight: number): SizeConfig => {
|
|
const basis = Math.min(cellWidth, cellHeight);
|
|
let config = defaultSizeConfig;
|
|
for (const [threshold, cfg] of sizeThresholds) {
|
|
if (basis >= threshold) config = cfg;
|
|
}
|
|
return config;
|
|
};
|
|
|
|
const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
|
|
const available = cellHeight - size.cardPadding * 2 - size.badgeHeight - size.gap;
|
|
return Math.max(1, Math.floor(available / size.rowHeight));
|
|
};
|
|
|
|
const MIN_CELL_WIDTH = 140;
|
|
const MIN_CELL_HEIGHT = 80;
|
|
const ADVANCED_MIN_CELL_WIDTH = 320;
|
|
const ADVANCED_MIN_CELL_HEIGHT = 420;
|
|
|
|
const getColCount = (width: number, height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
|
|
if (itemCount <= 1) return 1;
|
|
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth)));
|
|
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
|
|
const idealCols = Math.round(Math.sqrt(itemCount * aspectRatio));
|
|
return Math.min(maxCols, Math.max(1, idealCols));
|
|
};
|
|
|
|
interface MetricRowProps {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value: string;
|
|
progress?: { value: number; color: string };
|
|
size: SizeConfig;
|
|
}
|
|
|
|
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
|
|
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
|
|
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
|
{value}
|
|
</Text>
|
|
{progress && (
|
|
<Progress
|
|
value={progress.value}
|
|
color={progress.color}
|
|
size={getProgressTrackSize(size.progressSize)}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
)}
|
|
</Group>
|
|
);
|
|
|
|
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
|
|
<Group gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: size.rowHeight }}>
|
|
{icon}
|
|
<Text size={size.fontSize} c="dimmed" w={size.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
|
{label}
|
|
</Text>
|
|
<MetricValue value={value} progress={progress} size={size} />
|
|
</Group>
|
|
);
|
|
|
|
interface SystemCardProps {
|
|
system: BeszelSystemRow & { rowKey: string };
|
|
options: WidgetComponentProps<"beszelSystemGrid">["options"];
|
|
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
|
|
size: SizeConfig;
|
|
maxMetrics: number;
|
|
itemRadius: MantineSize;
|
|
integrationName: string;
|
|
isAdvanced: boolean;
|
|
onClick?: () => void;
|
|
}
|
|
|
|
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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showCpu, advanced),
|
|
},
|
|
{
|
|
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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showMemory, advanced),
|
|
},
|
|
{
|
|
key: "showDisk",
|
|
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
|
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
|
|
<HardDrive size={sz.iconSize} />
|
|
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
|
{t("metric.disk")}
|
|
</Text>
|
|
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
|
|
</Group>
|
|
),
|
|
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showDisk, advanced),
|
|
},
|
|
{
|
|
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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showGpu, advanced, 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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showLoadAvg, advanced, 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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showNet, advanced),
|
|
},
|
|
{
|
|
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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showTemp, advanced, s.temp !== null),
|
|
},
|
|
{
|
|
key: "showBattery",
|
|
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => {
|
|
const batteryIcons = [Battery, BatteryCharging] as const;
|
|
const Icon = batteryIcons[Math.min(1, s.battery?.[1] ?? 0)] ?? Battery;
|
|
return (
|
|
<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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showBattery, advanced, 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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showServices, advanced),
|
|
},
|
|
{
|
|
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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showUptime, advanced),
|
|
},
|
|
{
|
|
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"], advanced: boolean) =>
|
|
isBeszelGridMetricVisible(o.showAgent, advanced),
|
|
},
|
|
] as const;
|
|
|
|
const SystemCard = ({
|
|
system,
|
|
options,
|
|
t,
|
|
size,
|
|
maxMetrics,
|
|
itemRadius,
|
|
integrationName,
|
|
isAdvanced,
|
|
onClick,
|
|
}: SystemCardProps) => {
|
|
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options, isAdvanced));
|
|
const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
|
|
const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
|
|
|
|
return (
|
|
<Card
|
|
padding={size.cardPadding}
|
|
radius={itemRadius}
|
|
bg="transparent"
|
|
h="100%"
|
|
className={onClick ? classes.clickableCard : undefined}
|
|
style={{
|
|
overflow: "hidden",
|
|
display: "flex",
|
|
flexDirection: "column" as const,
|
|
border: "0.0625rem solid var(--border-color)",
|
|
color: "inherit",
|
|
font: "inherit",
|
|
textAlign: "left",
|
|
width: "100%",
|
|
}}
|
|
>
|
|
{onClick && (
|
|
<UnstyledButton
|
|
type="button"
|
|
className={classes.cardAction}
|
|
aria-label={system.name}
|
|
onClick={onClick}
|
|
/>
|
|
)}
|
|
<Group gap="xs" mb={2}>
|
|
<Badge
|
|
size={size.badgeSize}
|
|
variant="dot"
|
|
color={statusColorMap[system.status]}
|
|
styles={{ root: { textTransform: "none" } }}
|
|
>
|
|
{system.name}
|
|
</Badge>
|
|
</Group>
|
|
|
|
{isAdvanced && (
|
|
<SimpleGrid cols={2} spacing={4} verticalSpacing={2} mb={4}>
|
|
<SystemMetadata label={t("metadata.hostname")} value={system.hostname || "—"} />
|
|
<SystemMetadata label={t("metadata.os")} value={system.osName || "—"} />
|
|
<SystemMetadata label={t("metadata.cpuModel")} value={system.cpuModel || "—"} />
|
|
<SystemMetadata label={t("metadata.cores")} value={String(system.cores)} />
|
|
<SystemMetadata
|
|
label={t("metadata.memoryTotal")}
|
|
value={system.memoryTotal > 0 ? formatBytes(system.memoryTotal) : "—"}
|
|
/>
|
|
<SystemMetadata label={t("metadata.source")} value={integrationName} />
|
|
</SimpleGrid>
|
|
)}
|
|
|
|
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
|
|
{visibleMetrics.map((m) => m.render(system, t, size))}
|
|
</Stack>
|
|
{hiddenMetricCount > 0 && (
|
|
<Text size="xs" c="dimmed" ta="right">
|
|
+{hiddenMetricCount}
|
|
</Text>
|
|
)}
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
const SystemMetadata = ({ label, value }: { label: string; value: string }) => (
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text size="xs" c="dimmed">
|
|
{label}
|
|
</Text>
|
|
<Text size="xs" fw={500} truncate title={value}>
|
|
{value}
|
|
</Text>
|
|
</Box>
|
|
);
|
|
|
|
export default function BeszelSystemGridWidget({
|
|
options,
|
|
integrationIds,
|
|
isEditMode,
|
|
width,
|
|
height,
|
|
displayMode,
|
|
}: WidgetComponentProps<"beszelSystemGrid">) {
|
|
const t = useScopedI18n("widget.beszelSystemGrid");
|
|
const board = useRequiredBoard();
|
|
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
|
|
|
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
|
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
|
|
const { isPending } = systemsQuery;
|
|
const isAdvanced = displayMode === "advanced";
|
|
const integrationNames = new Map(results.map((result) => [result.integrationId, result.integrationName]));
|
|
|
|
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
|
|
|
if (isPending) {
|
|
return (
|
|
<Center h="100%">
|
|
<Loader size="sm" />
|
|
</Center>
|
|
);
|
|
}
|
|
|
|
if (filteredSystems.length === 0) {
|
|
return (
|
|
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
|
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
|
<IntegrationErrorIndicator results={results} />
|
|
</Group>
|
|
<Center h="100%">
|
|
<Stack align="center" gap="xs">
|
|
<IconServerOff size={28} opacity={0.5} />
|
|
<Text size="sm" c="dimmed">
|
|
{t("empty.noSystems")}
|
|
</Text>
|
|
</Stack>
|
|
</Center>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const minimumCellWidth = isAdvanced ? ADVANCED_MIN_CELL_WIDTH : MIN_CELL_WIDTH;
|
|
const cols = getColCount(width, height, filteredSystems.length, minimumCellWidth);
|
|
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
|
const rawCellHeight = height / rows;
|
|
const minimumCellHeight = isAdvanced ? ADVANCED_MIN_CELL_HEIGHT : MIN_CELL_HEIGHT;
|
|
const scrollEnabled = rawCellHeight < minimumCellHeight;
|
|
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
|
|
const cellWidth = width / cols;
|
|
const size = getSizeConfig(cellWidth, effectiveCellHeight);
|
|
const maxMetrics = isAdvanced ? Number.POSITIVE_INFINITY : getMaxVisibleMetrics(effectiveCellHeight, size);
|
|
|
|
return (
|
|
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
|
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
|
<IntegrationErrorIndicator results={results} />
|
|
</Group>
|
|
<Box
|
|
h="100%"
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
|
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
|
|
gap: size.gap,
|
|
overflow: scrollEnabled ? "auto" : "hidden",
|
|
}}
|
|
>
|
|
{filteredSystems.map((system) => {
|
|
const integrationId = system.rowKey.split(":")[0] ?? "";
|
|
const integrationName = integrationNames.get(integrationId) ?? "—";
|
|
const handleClick = isEditMode
|
|
? undefined
|
|
: () => openModal({ integrationId, systemId: system.id }, { title: system.name });
|
|
return (
|
|
<SystemCard
|
|
key={system.rowKey}
|
|
system={system}
|
|
options={options}
|
|
t={t}
|
|
size={size}
|
|
maxMetrics={maxMetrics}
|
|
itemRadius={board.itemRadius}
|
|
integrationName={integrationName}
|
|
isAdvanced={isAdvanced}
|
|
onClick={handleClick}
|
|
/>
|
|
);
|
|
})}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|