feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import { Box, Center, Group, Loader, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import {
Activity,
@@ -20,12 +20,14 @@ 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 { formatBytes } from "@homarr/common";
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 { getUsableWidgetQueryData } from "../common/query-state";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
@@ -38,16 +40,24 @@ import {
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { getBeszelTableVisibleMetricKeys } from "./display";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
type SystemRowWithKey = BeszelSystemRow & { rowKey: string; integrationName: string };
const columnAccessors = [
"name",
"integrationName",
"hostname",
"osName",
"cpuModel",
"cores",
"memoryTotal",
"cpu",
"memory",
"disk",
@@ -59,7 +69,7 @@ const columnAccessors = [
"services",
"uptime",
"agentVersion",
] as const satisfies readonly (keyof BeszelSystemRow)[];
] as const satisfies readonly (keyof SystemRowWithKey)[];
interface SizeConfig {
iconSize: number;
@@ -96,18 +106,23 @@ export default function BeszelSystemTableWidget({
boardId,
itemId,
setOptions,
displayMode,
}: 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 systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsData = getUsableWidgetQueryData(systemsQuery);
const results = useMemo(() => systemsData ?? [], [systemsData]);
const { isPending } = systemsQuery;
const isAdvanced = displayMode === "advanced";
const size = useMemo(() => getSizeConfig(width), [width]);
const visibleMetricKeys = useMemo(
() => getBeszelTableVisibleMetricKeys(options, width, isAdvanced),
[options, width, isAdvanced],
);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
@@ -125,6 +140,18 @@ export default function BeszelSystemTableWidget({
});
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const integrationNames = useMemo(
() => new Map(results.map((result) => [result.integrationId, result.integrationName])),
[results],
);
const systemsWithSource = useMemo(
() =>
filteredSystems.map((system) => ({
...system,
integrationName: integrationNames.get(system.rowKey.split(":")[0] ?? "") ?? "—",
})),
[filteredSystems, integrationNames],
);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
columnAccessor: options.sortBy,
@@ -138,16 +165,16 @@ export default function BeszelSystemTableWidget({
}, [options.sortBy, options.sortDirection]);
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
const accessor = sortStatus.columnAccessor as keyof SystemRowWithKey;
const dir = directionMultiplier[sortStatus.direction] ?? 1;
return [...filteredSystems].toSorted((a, b) => {
return [...systemsWithSource].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]);
}, [systemsWithSource, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
@@ -178,14 +205,62 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
<Indicator color={statusColorMap[record.status]} size={7} />
<Box w={7} h={7} bg={statusColorMap[record.status]} style={{ borderRadius: "50%", flexShrink: 0 }} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
</Text>
</Group>
),
},
options.showCpu && {
isAdvanced && {
accessor: "integrationName",
width: 140,
ellipsis: true,
title: t("column.source"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.integrationName}</Text>,
},
isAdvanced && {
accessor: "hostname",
width: 150,
ellipsis: true,
title: t("column.hostname"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.hostname || "—"}</Text>,
},
isAdvanced && {
accessor: "osName",
width: 160,
ellipsis: true,
title: t("column.os"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.osName || "—"}</Text>,
},
isAdvanced && {
accessor: "cpuModel",
width: 220,
ellipsis: true,
title: t("column.cpuModel"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cpuModel || "—"}</Text>,
},
isAdvanced && {
accessor: "cores",
width: 80,
title: t("column.cores"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cores}</Text>,
},
isAdvanced && {
accessor: "memoryTotal",
width: 120,
title: t("column.memoryTotal"),
sortable: true,
render: (record) => (
<Text size={size.fontSize}>{record.memoryTotal > 0 ? formatBytes(record.memoryTotal) : "—"}</Text>
),
},
visibleMetricKeys.has("showCpu") && {
accessor: "cpu",
width: 140,
title: (
@@ -197,7 +272,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
},
options.showMemory && {
visibleMetricKeys.has("showMemory") && {
accessor: "memory",
width: 140,
title: (
@@ -209,7 +284,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
},
options.showDisk && {
visibleMetricKeys.has("showDisk") && {
accessor: "disk",
width: 160,
title: (
@@ -229,7 +304,7 @@ export default function BeszelSystemTableWidget({
/>
),
},
options.showGpu && {
visibleMetricKeys.has("showGpu") && {
accessor: "gpu",
width: 140,
title: (
@@ -241,7 +316,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
},
options.showLoadAvg && {
visibleMetricKeys.has("showLoadAvg") && {
accessor: "loadAvg",
width: 100,
title: (
@@ -253,12 +328,17 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Indicator color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"} size={7} />
<Box
w={7}
h={7}
bg={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"}
style={{ borderRadius: "50%", flexShrink: 0 }}
/>
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
</Group>
),
},
options.showNet && {
visibleMetricKeys.has("showNet") && {
accessor: "netBytes",
width: 100,
title: (
@@ -274,7 +354,7 @@ export default function BeszelSystemTableWidget({
</Text>
),
},
options.showTemp && {
visibleMetricKeys.has("showTemp") && {
accessor: "temp",
width: 80,
title: (
@@ -286,7 +366,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
},
options.showBattery && {
visibleMetricKeys.has("showBattery") && {
accessor: "battery",
width: 70,
title: (
@@ -297,7 +377,7 @@ export default function BeszelSystemTableWidget({
),
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
},
options.showServices && {
visibleMetricKeys.has("showServices") && {
accessor: "services",
width: 80,
title: (
@@ -309,7 +389,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
},
options.showUptime && {
visibleMetricKeys.has("showUptime") && {
accessor: "uptime",
width: 90,
title: (
@@ -321,7 +401,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
},
options.showAgent && {
visibleMetricKeys.has("showAgent") && {
accessor: "agentVersion",
width: 90,
title: (
@@ -336,21 +416,7 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [
options.showAgent,
options.showBattery,
options.showCpu,
options.showDisk,
options.showGpu,
options.showLoadAvg,
options.showMemory,
options.showNet,
options.showServices,
options.showTemp,
options.showUptime,
size,
t,
]);
}, [t, size, visibleMetricKeys, isAdvanced]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
@@ -364,14 +430,12 @@ export default function BeszelSystemTableWidget({
const handleRowClick = useCallback(
({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
const integrationId = record.rowKey.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
},
[openModal],
);
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
@@ -380,10 +444,13 @@ export default function BeszelSystemTableWidget({
);
}
return (
const table = (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
</div>
<HomarrDataTable
isEditMode={isEditMode}
@@ -394,10 +461,12 @@ export default function BeszelSystemTableWidget({
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
idAccessor="rowKey"
storeColumnsKey={storeKey}
onRowClick={isEditMode ? undefined : handleRowClick}
/>
</div>
);
return table;
}