feat(widgets): persist Beszel and Docker table layouts (#6490)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-01 23:20:09 +02:00
1 parent f4449f9581
commit 27ca058088
19 files changed
+1171 -484

No files matched your search

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { parseColumnOrder, parseColumnWidths } from "../common/use-persisted-table-layout";
const columnAccessors = ["name", "cpu", "memory", "disk", "temp"];
describe("Beszel table column layout options", () => {
test("ignores malformed column layout JSON", () => {
expect(parseColumnOrder("{", columnAccessors)).toEqual([]);
expect(parseColumnWidths("[]", columnAccessors)).toEqual({});
});
test("removes stale and duplicate column accessors", () => {
expect(parseColumnOrder(JSON.stringify(["memory", "removed", "cpu", "memory"]), columnAccessors)).toEqual([
"memory",
"cpu",
]);
});
test("keeps only finite positive widths for known columns", () => {
expect(
parseColumnWidths(
JSON.stringify({
cpu: 140,
memory: -1,
disk: "160px",
removed: 100,
temp: null,
}),
columnAccessors,
),
).toEqual({ cpu: 140 });
});
});
@@ -1,11 +1,8 @@
"use client";
import "./styles.css";
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import {
Activity,
Battery,
@@ -20,10 +17,16 @@ import {
} from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import {
@@ -43,6 +46,21 @@ const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
const columnAccessors = [
"name",
"cpu",
"memory",
"disk",
"gpu",
"loadAvg",
"netBytes",
"temp",
"battery",
"services",
"uptime",
"agentVersion",
] as const satisfies readonly (keyof BeszelSystemRow)[];
interface SizeConfig {
iconSize: number;
fontSize: "xs" | "sm";
@@ -75,15 +93,36 @@ export default function BeszelSystemTableWidget({
integrationIds,
isEditMode,
width,
boardId,
itemId,
setOptions,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal);
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const size = getSizeConfig(width);
const size = useMemo(() => getSizeConfig(width), [width]);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
showErrorNotification({
title: t("error.layoutSaveTitle"),
message: t("error.layoutSaveMessage"),
}),
});
const persistLayout = useTableLayoutPersistence({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
});
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
@@ -91,6 +130,12 @@ export default function BeszelSystemTableWidget({
columnAccessor: options.sortBy,
direction: options.sortDirection as "asc" | "desc",
});
useEffect(() => {
setSortStatus({
columnAccessor: options.sortBy,
direction: options.sortDirection as "asc" | "desc",
});
}, [options.sortBy, options.sortDirection]);
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
@@ -122,6 +167,8 @@ export default function BeszelSystemTableWidget({
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
{
accessor: "name",
width: 160,
ellipsis: true,
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
@@ -130,7 +177,7 @@ export default function BeszelSystemTableWidget({
),
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
<Indicator color={statusColorMap[record.status]} size={7} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
@@ -140,6 +187,7 @@ export default function BeszelSystemTableWidget({
},
options.showCpu && {
accessor: "cpu",
width: 140,
title: (
<Group gap={4} wrap="nowrap">
<Cpu size={size.iconSize} />
@@ -151,6 +199,7 @@ export default function BeszelSystemTableWidget({
},
options.showMemory && {
accessor: "memory",
width: 140,
title: (
<Group gap={4} wrap="nowrap">
<MemoryStick size={size.iconSize} />
@@ -162,6 +211,7 @@ export default function BeszelSystemTableWidget({
},
options.showDisk && {
accessor: "disk",
width: 160,
title: (
<Group gap={4} wrap="nowrap">
<HardDrive size={size.iconSize} />
@@ -181,6 +231,7 @@ export default function BeszelSystemTableWidget({
},
options.showGpu && {
accessor: "gpu",
width: 140,
title: (
<Group gap={4} wrap="nowrap">
<Monitor size={size.iconSize} />
@@ -192,6 +243,7 @@ export default function BeszelSystemTableWidget({
},
options.showLoadAvg && {
accessor: "loadAvg",
width: 100,
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
@@ -208,6 +260,7 @@ export default function BeszelSystemTableWidget({
},
options.showNet && {
accessor: "netBytes",
width: 100,
title: (
<Group gap={4} wrap="nowrap">
<Network size={size.iconSize} />
@@ -223,6 +276,7 @@ export default function BeszelSystemTableWidget({
},
options.showTemp && {
accessor: "temp",
width: 80,
title: (
<Group gap={4} wrap="nowrap">
<Thermometer size={size.iconSize} />
@@ -234,6 +288,7 @@ export default function BeszelSystemTableWidget({
},
options.showBattery && {
accessor: "battery",
width: 70,
title: (
<Group gap={4} wrap="nowrap">
<Battery size={size.iconSize} />
@@ -244,6 +299,7 @@ export default function BeszelSystemTableWidget({
},
options.showServices && {
accessor: "services",
width: 80,
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
@@ -255,6 +311,7 @@ export default function BeszelSystemTableWidget({
},
options.showUptime && {
accessor: "uptime",
width: 90,
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
@@ -266,6 +323,7 @@ export default function BeszelSystemTableWidget({
},
options.showAgent && {
accessor: "agentVersion",
width: 90,
title: (
<Group gap={4} wrap="nowrap">
<Wifi size={size.iconSize} />
@@ -278,12 +336,39 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [options, t, size]);
}, [
options.showAgent,
options.showBattery,
options.showCpu,
options.showDisk,
options.showGpu,
options.showLoadAvg,
options.showMemory,
options.showNet,
options.showServices,
options.showTemp,
options.showUptime,
size,
t,
]);
const handleRowClick = ({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
};
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
storeKeyPrefix: "beszel-system-table",
onLayoutChange: persistLayout,
});
const handleRowClick = useCallback(
({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
},
[openModal],
);
if (systemsError) throw systemsError;
@@ -300,20 +385,17 @@ export default function BeszelSystemTableWidget({
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
</div>
<DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
fz={size.fontSize}
records={sortedSystems}
columns={columns}
columns={effectiveColumns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
height="100%"
className="beszel-table"
storeColumnsKey={storeKey}
onRowClick={isEditMode ? undefined : handleRowClick}
/>
</div>
@@ -29,28 +29,36 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({
defaultValue: "all",
options: statusOptions,
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 }),
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
}),
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 }),
}));
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
},
);
},
errors: {
INTERNAL_SERVER_ERROR: {
@@ -1,47 +0,0 @@
/* Make the DataTable transparent so the board card's opacity-controlled
background shows through. The mantine-datatable defaults to
var(--mantine-color-body) which is opaque. */
.beszel-table,
.beszel-table .mantine-datatable-table,
.beszel-table .mantine-datatable-table thead,
.beszel-table .mantine-datatable-table tbody,
.beszel-table .mantine-datatable-table tfoot,
.beszel-table th,
.beszel-table td {
background-color: transparent !important;
}
.beszel-table .mantine-datatable-table tr {
background-color: transparent;
}
.beszel-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.beszel-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.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;
}