feat(widgets): persist Beszel and Docker table layouts (#6490)
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user