feat(beszel): add Beszel integration with system monitoring widgets (#5900)
This commit is contained in:
1 parent
5d1f268438
commit
a4db9cf0dc
41 files changed
+3474
-14
No files matched your search
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Group, Indicator, Progress, Text } from "@mantine/core";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
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 };
|
||||
|
||||
type SystemRowWithKey = BeszelSystemRow & { _key: string };
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
fontSize: "xs" | "10px";
|
||||
progressSize: "xs" | "sm";
|
||||
cellPadding: number;
|
||||
valueMiw: number;
|
||||
}
|
||||
|
||||
const getSizeConfig = (width: number): SizeConfig => {
|
||||
if (width < 400) {
|
||||
return { iconSize: 10, fontSize: "10px", progressSize: "xs", cellPadding: 2, valueMiw: 30 };
|
||||
}
|
||||
return { iconSize: 14, fontSize: "xs", progressSize: "sm", cellPadding: 4, valueMiw: 38 };
|
||||
};
|
||||
|
||||
export default function BeszelSystemTableWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
}: WidgetComponentProps<"beszelSystemTable">) {
|
||||
const t = useScopedI18n("widget.beszelSystemTable");
|
||||
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
|
||||
const size = getSizeConfig(width);
|
||||
|
||||
useBeszelSystemsSubscription(integrationIds, !isEditMode);
|
||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
|
||||
columnAccessor: options.sortBy,
|
||||
direction: options.sortDirection as "asc" | "desc",
|
||||
});
|
||||
|
||||
const sortedSystems = useMemo(() => {
|
||||
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
|
||||
const dir = directionMultiplier[sortStatus.direction] ?? 1;
|
||||
return [...filteredSystems].toSorted((a, b) => {
|
||||
const aVal = a[accessor] ?? 0;
|
||||
const bVal = b[accessor] ?? 0;
|
||||
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir;
|
||||
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir;
|
||||
return 0;
|
||||
});
|
||||
}, [filteredSystems, sortStatus]);
|
||||
|
||||
const PercentCell = ({ value }: { value: number }) => (
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<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 }} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
|
||||
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
|
||||
{
|
||||
accessor: "name",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Server size={size.iconSize} />
|
||||
<Text inherit>{t("column.system")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Indicator color={statusColorMap[record.status]} size={7} />
|
||||
<Text size={size.fontSize} fw={500} truncate>
|
||||
{record.name}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
options.showCpu && {
|
||||
accessor: "cpu",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Cpu size={size.iconSize} />
|
||||
<Text inherit>{t("column.cpu")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.cpu} />,
|
||||
},
|
||||
options.showMemory && {
|
||||
accessor: "memory",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<MemoryStick size={size.iconSize} />
|
||||
<Text inherit>{t("column.memory")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.memory} />,
|
||||
},
|
||||
options.showDisk && {
|
||||
accessor: "disk",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<HardDrive size={size.iconSize} />
|
||||
<Text inherit>{t("column.disk")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.disk} />,
|
||||
},
|
||||
options.showGpu && {
|
||||
accessor: "gpu",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Monitor size={size.iconSize} />
|
||||
<Text inherit>{t("column.gpu")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.gpu} />,
|
||||
},
|
||||
options.showLoadAvg && {
|
||||
accessor: "loadAvg",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Activity size={size.iconSize} />
|
||||
<Text inherit>{t("column.loadAvg")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Indicator color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"} size={7} />
|
||||
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
options.showNet && {
|
||||
accessor: "netBytes",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Network size={size.iconSize} />
|
||||
<Text inherit>{t("column.net")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Text size={size.fontSize} style={{ whiteSpace: "nowrap" }}>
|
||||
{formatByteRate(record.netBytes)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
options.showTemp && {
|
||||
accessor: "temp",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Thermometer size={size.iconSize} />
|
||||
<Text inherit>{t("column.temp")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
|
||||
},
|
||||
options.showBattery && {
|
||||
accessor: "battery",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Battery size={size.iconSize} />
|
||||
<Text inherit>{t("column.battery")}</Text>
|
||||
</Group>
|
||||
),
|
||||
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
|
||||
},
|
||||
options.showServices && {
|
||||
accessor: "services",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Server size={size.iconSize} />
|
||||
<Text inherit>{t("column.services")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
|
||||
},
|
||||
options.showUptime && {
|
||||
accessor: "uptime",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Activity size={size.iconSize} />
|
||||
<Text inherit>{t("column.uptime")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
|
||||
},
|
||||
options.showAgent && {
|
||||
accessor: "agentVersion",
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Wifi size={size.iconSize} />
|
||||
<Text inherit>{t("column.agent")}</Text>
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.agentVersion}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
||||
}, [options, t, size]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
fz={size.fontSize}
|
||||
records={sortedSystems}
|
||||
columns={columns}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("noRecords")}
|
||||
idAccessor="_key"
|
||||
height="100%"
|
||||
className="beszel-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { statusOptions } from "../beszel/_shared/options";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "name", label: "System" },
|
||||
{ value: "cpu", label: "CPU" },
|
||||
{ value: "memory", label: "Memory" },
|
||||
{ value: "disk", label: "Disk" },
|
||||
{ value: "gpu", label: "GPU" },
|
||||
{ value: "loadAvg", label: "Load Avg" },
|
||||
{ value: "netBytes", label: "Net" },
|
||||
{ value: "temp", label: "Temp" },
|
||||
{ value: "services", label: "Services" },
|
||||
{ value: "uptime", label: "Uptime" },
|
||||
{ value: "agentVersion", label: "Agent" },
|
||||
];
|
||||
|
||||
const sortDirectionOptions = [
|
||||
{ value: "asc", label: "Ascending" },
|
||||
{ value: "desc", label: "Descending" },
|
||||
];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
|
||||
icon: IconTable,
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
|
||||
sortBy: factory.select({ defaultValue: "name", options: sortOptions }),
|
||||
sortDirection: factory.select({ defaultValue: "asc", options: sortDirectionOptions }),
|
||||
showCpu: factory.switch({ defaultValue: true }),
|
||||
showMemory: factory.switch({ defaultValue: true }),
|
||||
showDisk: factory.switch({ defaultValue: true }),
|
||||
showGpu: factory.switch({ defaultValue: true }),
|
||||
showLoadAvg: factory.switch({ defaultValue: true }),
|
||||
showNet: factory.switch({ defaultValue: true }),
|
||||
showTemp: factory.switch({ defaultValue: true }),
|
||||
showBattery: factory.switch({ defaultValue: true }),
|
||||
showServices: factory.switch({ defaultValue: true }),
|
||||
showUptime: factory.switch({ defaultValue: true }),
|
||||
showAgent: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,24 @@
|
||||
.beszel-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.beszel-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
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;
|
||||
}
|
||||
|
||||
.beszel-table th:hover .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
Reference in new issue
Block a user