From e0f5ab9f6bd1a21ea84ab49994a7ada4c087cbb6 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 30 Jul 2026 18:21:21 +0200 Subject: [PATCH] feat(widgets): persist table layouts --- .../widgets/beszel-system-table/index.mdx | 9 +- .../docs/widgets/docker-containers/index.mdx | 7 +- .../docs/widgets/docker-containers/index.ts | 6 + packages/translation/src/lang/en.json | 21 +- .../src/beszel-system-table/component.spec.ts | 28 + .../src/beszel-system-table/component.tsx | 198 ++++- .../widgets/src/beszel-system-table/index.ts | 50 +- packages/widgets/src/docker/component.spec.ts | 31 + packages/widgets/src/docker/component.tsx | 751 ++++++++++++------ packages/widgets/src/docker/index.ts | 52 +- packages/widgets/src/docker/styles.css | 45 ++ 11 files changed, 915 insertions(+), 283 deletions(-) create mode 100644 packages/widgets/src/beszel-system-table/component.spec.ts create mode 100644 packages/widgets/src/docker/component.spec.ts create mode 100644 packages/widgets/src/docker/styles.css diff --git a/apps/docs/docs/widgets/beszel-system-table/index.mdx b/apps/docs/docs/widgets/beszel-system-table/index.mdx index df68fcc1b..20c8ba52d 100644 --- a/apps/docs/docs/widgets/beszel-system-table/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx @@ -17,10 +17,17 @@ import { beszelIntegration } from '@site/docs/integrations/beszel'; categories={['System Monitoring']} /> -This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. +This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. +### Interactions + +- **Reorder columns** by dragging their headers +- **Resize columns** by dragging a header's right edge +- **Sort systems** by selecting a sortable column header +- **Open system statistics** by selecting a row + ### Supported Integrations { + test("ignores malformed column layout JSON", () => { + expect(parseColumnOrder("{")).toEqual([]); + expect(parseColumnWidths("[]")).toEqual({}); + }); + + test("removes stale and duplicate column accessors", () => { + expect(parseColumnOrder(JSON.stringify(["memory", "removed", "cpu", "memory"]))).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, + }), + ), + ).toEqual({ cpu: 140 }); + }); +}); diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 5352eb170..d11ced75c 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -2,10 +2,10 @@ import "./styles.css"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, 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 { DataTable, useDataTableColumns } from "mantine-datatable"; import { Activity, Battery, @@ -21,6 +21,7 @@ import { import { clientApi } from "@homarr/api/client"; import { useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; @@ -43,6 +44,21 @@ const directionMultiplier: Record = { 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"; @@ -70,11 +86,48 @@ const getSizeConfig = (width: number): SizeConfig => { }; }; +export const parseColumnOrder = (value: string): string[] => { + if (!value) return []; + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) return []; + + const allowed = new Set(columnAccessors); + return parsed.filter( + (accessor, index): accessor is string => + typeof accessor === "string" && allowed.has(accessor) && parsed.indexOf(accessor) === index, + ); + } catch { + return []; + } +}; + +export const parseColumnWidths = (value: string): Record => { + if (!value) return {}; + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + + const allowed = new Set(columnAccessors); + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, number] => + allowed.has(entry[0]) && typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] > 0, + ), + ); + } catch { + return {}; + } +}; + export default function BeszelSystemTableWidget({ options, integrationIds, isEditMode, width, + boardId, + itemId, + setOptions, }: WidgetComponentProps<"beszelSystemTable">) { const t = useScopedI18n("widget.beszelSystemTable"); const { openModal } = useModalAction(BeszelSystemStatsModal); @@ -83,7 +136,25 @@ export default function BeszelSystemTableWidget({ 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 = useCallback( + (newOptions: Partial>) => { + setOptions({ newOptions }); + if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions }); + }, + [boardId, itemId, saveItemOptions, setOptions], + ); + + const savedOrder = useMemo(() => parseColumnOrder(options.columnOrder), [options.columnOrder]); + const savedWidths = useMemo(() => parseColumnWidths(options.columnWidths), [options.columnWidths]); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); @@ -91,6 +162,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 +199,8 @@ export default function BeszelSystemTableWidget({ const cols: (DataTableColumn | false)[] = [ { accessor: "name", + width: 160, + ellipsis: true, title: ( @@ -130,7 +209,7 @@ export default function BeszelSystemTableWidget({ ), sortable: true, render: (record) => ( - + {record.name} @@ -140,6 +219,7 @@ export default function BeszelSystemTableWidget({ }, options.showCpu && { accessor: "cpu", + width: 140, title: ( @@ -151,6 +231,7 @@ export default function BeszelSystemTableWidget({ }, options.showMemory && { accessor: "memory", + width: 140, title: ( @@ -162,6 +243,7 @@ export default function BeszelSystemTableWidget({ }, options.showDisk && { accessor: "disk", + width: 160, title: ( @@ -181,6 +263,7 @@ export default function BeszelSystemTableWidget({ }, options.showGpu && { accessor: "gpu", + width: 140, title: ( @@ -192,6 +275,7 @@ export default function BeszelSystemTableWidget({ }, options.showLoadAvg && { accessor: "loadAvg", + width: 100, title: ( @@ -208,6 +292,7 @@ export default function BeszelSystemTableWidget({ }, options.showNet && { accessor: "netBytes", + width: 100, title: ( @@ -223,6 +308,7 @@ export default function BeszelSystemTableWidget({ }, options.showTemp && { accessor: "temp", + width: 80, title: ( @@ -234,6 +320,7 @@ export default function BeszelSystemTableWidget({ }, options.showBattery && { accessor: "battery", + width: 70, title: ( @@ -244,6 +331,7 @@ export default function BeszelSystemTableWidget({ }, options.showServices && { accessor: "services", + width: 80, title: ( @@ -255,6 +343,7 @@ export default function BeszelSystemTableWidget({ }, options.showUptime && { accessor: "uptime", + width: 90, title: ( @@ -266,6 +355,7 @@ export default function BeszelSystemTableWidget({ }, options.showAgent && { accessor: "agentVersion", + width: 90, title: ( @@ -280,10 +370,87 @@ export default function BeszelSystemTableWidget({ return cols.filter(Boolean) as DataTableColumn[]; }, [options, t, size]); - const handleRowClick = ({ record }: { record: SystemRowWithKey }) => { - const integrationId = record._key.split(":")[0] ?? ""; - openModal({ integrationId, systemId: record.id }, { title: record.name }); - }; + const visibleAccessors = useMemo(() => columns.map(({ accessor }) => String(accessor)), [columns]); + const visibleAccessorSet = useMemo(() => new Set(visibleAccessors), [visibleAccessors]); + const restoredOrder = useMemo( + () => [ + ...savedOrder.filter((accessor) => visibleAccessorSet.has(accessor)), + ...visibleAccessors.filter((accessor) => !savedOrder.includes(accessor)), + ], + [savedOrder, visibleAccessors, visibleAccessorSet], + ); + const storeKey = `beszel-system-table-${itemId ?? "preview"}-${[...visibleAccessors].toSorted().join(",")}`; + const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = + useDataTableColumns({ + key: storeKey, + columns, + }); + + const lastStoreKey = useRef(storeKey); + const hydrated = useRef(false); + useEffect(() => { + if (lastStoreKey.current !== storeKey) { + lastStoreKey.current = storeKey; + hydrated.current = false; + } + if (hydrated.current) return; + + if (restoredOrder.length > 0) setColumnsOrder(restoredOrder); + const visibleWidths = Object.entries(savedWidths) + .filter(([accessor]) => visibleAccessorSet.has(accessor)) + .map(([accessor, columnWidth]) => ({ accessor, width: columnWidth })); + if (visibleWidths.length > 0) setMultipleColumnWidths(visibleWidths); + + requestAnimationFrame(() => { + hydrated.current = true; + }); + }, [restoredOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths, storeKey, visibleAccessorSet]); + + const previousOrder = useRef(columnsOrder); + const previousWidths = useRef(columnsWidth); + useEffect(() => { + if (!hydrated.current) return; + + const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(previousOrder.current); + const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(previousWidths.current); + previousOrder.current = columnsOrder; + previousWidths.current = columnsWidth; + if (!orderChanged && !widthsChanged) return; + + const newLayout: Partial> = {}; + if (orderChanged) { + const fullOrder = [...savedOrder, ...columnAccessors.filter((accessor) => !savedOrder.includes(accessor))]; + const reorderedVisibleColumns = [...columnsOrder]; + const mergedOrder = fullOrder.map((accessor) => + visibleAccessorSet.has(accessor) ? (reorderedVisibleColumns.shift() ?? accessor) : accessor, + ); + newLayout.columnOrder = JSON.stringify(mergedOrder); + } + if (widthsChanged) { + const widthMap: Record = { ...savedWidths }; + for (const entry of columnsWidth) { + const accessor = Object.keys(entry)[0]; + if (!accessor || !visibleAccessorSet.has(accessor)) continue; + const columnWidth = entry[accessor as keyof typeof entry]; + if (typeof columnWidth === "number" && Number.isFinite(columnWidth) && columnWidth > 0) { + widthMap[accessor] = columnWidth; + } else if (typeof columnWidth === "string" && columnWidth.endsWith("px")) { + const parsedWidth = Number.parseInt(columnWidth, 10); + if (Number.isFinite(parsedWidth) && parsedWidth > 0) widthMap[accessor] = parsedWidth; + } + } + newLayout.columnWidths = JSON.stringify(widthMap); + } + persistLayout(newLayout); + }, [columnsOrder, columnsWidth, persistLayout, savedOrder, savedWidths, visibleAccessorSet]); + + 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; @@ -305,15 +472,28 @@ export default function BeszelSystemTableWidget({ withTableBorder={false} borderRadius={0} highlightOnHover + striped="odd" + stripedColor={{ dark: "dark.7", light: "gray.0" }} + highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }} + verticalAlign="center" 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} + textSelectionDisabled + defaultColumnProps={{ + noWrap: true, + draggable: true, + resizable: true, + cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }), + }} + scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} onRowClick={isEditMode ? undefined : handleRowClick} /> diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index ba90cb5e9..e31bbc597 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -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: { diff --git a/packages/widgets/src/docker/component.spec.ts b/packages/widgets/src/docker/component.spec.ts new file mode 100644 index 000000000..effeb7bef --- /dev/null +++ b/packages/widgets/src/docker/component.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; + +import { parseColumnOrder, parseColumnWidths } from "./component"; + +describe("Docker table column layout options", () => { + test("ignores malformed column layout JSON", () => { + expect(parseColumnOrder("{")).toEqual([]); + expect(parseColumnWidths("[]")).toEqual({}); + }); + + test("removes stale and duplicate column accessors", () => { + expect(parseColumnOrder(JSON.stringify(["memoryUsage", "removed", "name", "memoryUsage"]))).toEqual([ + "memoryUsage", + "name", + ]); + }); + + test("keeps only finite positive widths for known columns", () => { + expect( + parseColumnWidths( + JSON.stringify({ + name: 180, + state: -1, + host: "120px", + removed: 100, + actions: null, + }), + ), + ).toEqual({ name: 180 }); + }); +}); diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index e56cd5cf7..b54332b9d 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -1,23 +1,90 @@ "use client"; -import { useMemo } from "react"; -import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; -import type { IconProps } from "@tabler/icons-react"; -import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise } from "@tabler/icons-react"; -import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table"; -import { MantineReactTable } from "mantine-react-table"; +import "./styles.css"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core"; +import { + IconBrandDocker, + IconCategoryPlus, + IconDots, + IconFileText, + IconPlayerPlay, + IconPlayerStop, + IconRefresh, + IconRotateClockwise, + IconTrash, +} from "@tabler/icons-react"; +import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; +import { DataTable, useDataTableColumns } from "mantine-datatable"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { formatBytes, useTimeAgo } from "@homarr/common"; import type { ContainerState } from "@homarr/docker"; import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; +import { useModalAction } from "@homarr/modals"; +import { AddDockerAppToHomarr } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; +type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number]; +type ContainerAction = "start" | "stop" | "restart" | "remove"; + +interface ContextMenuState { + x: number; + y: number; + container: DockerContainer; +} + +interface ContainerActionHandlers { + onAction: (action: ContainerAction, container: DockerContainer) => void; + onAddToHomarr: (container: DockerContainer) => void; + onOpenLogs: (container: DockerContainer) => void; +} + +const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const; + +const createContainerLogsPath = (container: Pick) => + `/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`; + +export const parseColumnOrder = (value: string): string[] => { + if (!value) return []; + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) return []; + + const allowed = new Set(columnAccessors); + return parsed.filter( + (accessor, index): accessor is string => + typeof accessor === "string" && allowed.has(accessor) && parsed.indexOf(accessor) === index, + ); + } catch { + return []; + } +}; + +export const parseColumnWidths = (value: string): Record => { + if (!value) return {}; + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + + const allowed = new Set(columnAccessors); + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, number] => + allowed.has(entry[0]) && typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] > 0, + ), + ); + } catch { + return {}; + } +}; + const ContainerStateBadge = ({ state }: { state: ContainerState }) => { const t = useScopedI18n("docker.field.state.option"); @@ -28,276 +95,373 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => { ); }; -const actionIconIconStyle: IconProps["style"] = { - height: "var(--ai-icon-size)", - width: "var(--ai-icon-size)", -}; - const createColumns = ( t: ReturnType>, -): MRT_ColumnDef[] => [ + handlers: ContainerActionHandlers, + sortingEnabled: boolean, +): DataTableColumn[] => [ { - id: "name", - accessorKey: "name", - header: t("field.name.label"), - Cell({ renderedCellValue, row }) { - return ( - - - - {renderedCellValue} - - - ); - }, - }, - { - id: "state", - accessorKey: "state", - size: 100, - header: t("field.state.label"), - Cell({ row }) { - return ; - }, - }, - { - id: "host", - accessorKey: "host", - size: 100, - header: t("field.host.label"), - Cell({ row }) { - return ( - - {row.original.host} + accessor: "name", + title: t("field.name.label"), + width: 180, + ellipsis: true, + sortable: sortingEnabled, + render: (container) => ( + + + {container.name.at(0)?.toUpperCase()} + + + {container.name} - ); - }, + + ), }, { - id: "cpuUsage", - sortingFn: (rowA, rowB) => { - const cpuUsageA = safeValue(rowA.original.cpuUsage); - const cpuUsageB = safeValue(rowB.original.cpuUsage); - - return cpuUsageA - cpuUsageB; - }, - accessorKey: "cpuUsage", - size: 80, - header: t("field.stats.cpu.label"), - Cell({ row }) { - const cpuUsage = safeValue(row.original.cpuUsage); - + accessor: "state", + title: t("field.state.label"), + width: 100, + sortable: sortingEnabled, + render: (container) => , + }, + { + accessor: "host", + title: t("field.host.label"), + width: 120, + sortable: sortingEnabled, + ellipsis: true, + render: (container) => ( + + {container.host} + + ), + }, + { + accessor: "cpuUsage", + title: t("field.stats.cpu.label"), + width: 80, + sortable: sortingEnabled, + render: (container) => { + const cpuUsage = safeValue(container.cpuUsage); return ( - + {cpuUsage.toFixed(2)}% ); }, }, { - id: "memoryUsage", - sortingFn: (rowA, rowB) => { - const memoryUsageA = safeValue(rowA.original.memoryUsage); - const memoryUsageB = safeValue(rowB.original.memoryUsage); - - return memoryUsageA - memoryUsageB; - }, - accessorKey: "memoryUsage", - size: 80, - header: t("field.stats.memory.label"), - Cell({ row }) { - const bytesUsage = safeValue(row.original.memoryUsage); - + accessor: "memoryUsage", + title: t("field.stats.memory.label"), + width: 100, + sortable: sortingEnabled, + render: (container) => { + const memoryUsage = safeValue(container.memoryUsage); return ( - - {formatBytes(bytesUsage)} + + {formatBytes(memoryUsage)} ); }, }, { - id: "actions", - accessorKey: "actions", - size: 80, - header: t("action.title"), - enableSorting: false, - Cell({ row }) { - const utils = clientApi.useUtils(); - // eslint-disable-next-line no-restricted-syntax - const onSettled = async () => { - await utils.docker.getContainers.invalidate(); - }; - const { mutateAsync: startContainer } = clientApi.docker.startAll.useMutation({ onSettled }); - const { mutateAsync: stopContainer } = clientApi.docker.stopAll.useMutation({ onSettled }); - const { mutateAsync: restartContainer } = clientApi.docker.restartAll.useMutation({ onSettled }); - - const handleActionAsync = async (action: "start" | "stop" | "restart") => { - const mutation = action === "start" ? startContainer : action === "stop" ? stopContainer : restartContainer; - - await mutation( - { ids: [row.original.id] }, - { - onSuccess() { - showSuccessNotification({ - title: t(`action.${action}.notification.success.title`), - message: t(`action.${action}.notification.success.message`), - }); - }, - onError() { - showErrorNotification({ - title: t(`action.${action}.notification.error.title`), - message: t(`action.${action}.notification.error.message`), - }); - }, - }, - ); - }; - - return ( - - - handleActionAsync(row.original.state === "running" ? "stop" : "start")} - > - {row.original.state === "running" ? ( - - ) : ( - - )} - - - - handleActionAsync("restart")}> - - - - - ); - }, + accessor: "actions", + title: "", + width: 44, + textAlign: "right", + render: (container) => , }, ]; -export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) { +const compareContainers = ( + first: DockerContainer, + second: DockerContainer, + accessor: DataTableSortStatus["columnAccessor"], +) => { + if (accessor === "cpuUsage") return safeValue(first.cpuUsage) - safeValue(second.cpuUsage); + if (accessor === "memoryUsage") return safeValue(first.memoryUsage) - safeValue(second.memoryUsage); + + const firstValue = first[accessor as "name" | "state" | "host"]; + const secondValue = second[accessor as "name" | "state" | "host"]; + return String(firstValue ?? "").localeCompare(String(secondValue ?? "")); +}; + +const useContainerAction = (action: ContainerAction) => { + const t = useScopedI18n("docker.action"); + const utils = clientApi.useUtils(); + + return clientApi.docker[`${action}All`].useMutation({ + async onSettled() { + await utils.docker.getContainers.invalidate(); + }, + onSuccess() { + showSuccessNotification({ + title: t(`${action}.notification.success.title`), + message: t(`${action}.notification.success.message`), + }); + }, + onError() { + showErrorNotification({ + title: t(`${action}.notification.error.title`), + message: t(`${action}.notification.error.message`), + }); + }, + }); +}; + +export default function DockerWidget({ + options, + width, + isEditMode, + boardId, + itemId, + setOptions, +}: WidgetComponentProps<"dockerContainers">) { const t = useScopedI18n("docker"); + const tWidget = useScopedI18n("widget.dockerContainers"); + const router = useRouter(); + const { openModal } = useModalAction(AddDockerAppToHomarr); const isTiny = width <= 256; const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery(); - const containers = data?.containers ?? []; + const containers = useMemo(() => data?.containers ?? [], [data?.containers]); const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const relativeTime = useTimeAgo(timestamp); - const totalContainers = containers.length; + const { mutate: startContainer } = useContainerAction("start"); + const { mutate: stopContainer } = useContainerAction("stop"); + const { mutate: restartContainer } = useContainerAction("restart"); + const { mutate: removeContainer } = useContainerAction("remove"); + const handleContainerAction = useCallback( + (action: ContainerAction, container: DockerContainer) => { + if (action === "start") startContainer({ ids: [container.id] }); + else if (action === "stop") stopContainer({ ids: [container.id] }); + else if (action === "restart") restartContainer({ ids: [container.id] }); + else removeContainer({ ids: [container.id] }); + }, + [removeContainer, restartContainer, startContainer, stopContainer], + ); + const handleOpenLogs = useCallback( + (container: DockerContainer) => router.push(createContainerLogsPath(container)), + [router], + ); + const handleAddToHomarr = useCallback( + (container: DockerContainer) => openModal({ selectedContainers: [container] }), + [openModal], + ); + const actionHandlers = useMemo( + () => ({ + onAction: handleContainerAction, + onAddToHomarr: handleAddToHomarr, + onOpenLogs: handleOpenLogs, + }), + [handleAddToHomarr, handleContainerAction, handleOpenLogs], + ); - const totals = useMemo(() => { - return containers.reduce( - (acc, container) => { - acc.cpu += safeValue(container.cpuUsage); - acc.memory += safeValue(container.memoryUsage); - return acc; - }, - { cpu: 0, memory: 0 }, - ); - }, [containers]); - - const columns = useMemo(() => createColumns(t), [t]); - - const columnVisibility: MRT_VisibilityState = { - name: options.columns.includes("name"), - state: options.columns.includes("state"), - host: options.columns.includes("host"), - cpuUsage: options.columns.includes("cpuUsage"), - memoryUsage: options.columns.includes("memoryUsage"), - actions: options.columns.includes("actions"), - }; - - const table = useTranslatedMantineReactTable({ - columns, - data: containers, - enablePagination: false, - enableTopToolbar: false, - enableBottomToolbar: false, - enableColumnActions: false, - enableSorting: options.enableRowSorting && !isEditMode, - enableStickyHeader: false, - enableColumnOrdering: false, - enableRowSelection: false, - enableFullScreenToggle: false, - enableGlobalFilter: false, - enableDensityToggle: false, - enableFilters: false, - enableHiding: false, - initialState: { - sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }], - density: "xs", - }, - state: { - columnVisibility, - }, - mantinePaperProps: { - flex: 1, - withBorder: false, - shadow: undefined, - }, - mantineTableProps: { - className: "docker-widget-table", - style: { - tableLayout: "fixed", - }, - }, - mantineTableHeadProps: { - fz: "xs", - }, - mantineTableHeadCellProps: { - p: 4, - }, - mantineTableBodyCellProps: { - p: 4, - }, - mantineTableContainerProps: { - style: { - height: "100%", - }, - }, + const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({ + onError: () => + showErrorNotification({ + title: tWidget("error.layoutSaveTitle"), + message: tWidget("error.layoutSaveMessage"), + }), }); + const persistLayout = useCallback( + (newOptions: Partial>) => { + setOptions({ newOptions }); + if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions }); + }, + [boardId, itemId, saveItemOptions, setOptions], + ); - if (options.columns.length === 0) + const savedOrder = useMemo(() => parseColumnOrder(options.columnOrder), [options.columnOrder]); + const savedWidths = useMemo(() => parseColumnWidths(options.columnWidths), [options.columnWidths]); + + const defaultDirection = options.descendingDefaultSort ? "desc" : "asc"; + const [sortStatus, setSortStatus] = useState>({ + columnAccessor: options.defaultSort, + direction: defaultDirection, + }); + useEffect(() => { + setSortStatus({ columnAccessor: options.defaultSort, direction: defaultDirection }); + }, [defaultDirection, options.defaultSort]); + + const sortedContainers = useMemo(() => { + const multiplier = sortStatus.direction === "desc" ? -1 : 1; + return [...containers].toSorted( + (first, second) => compareContainers(first, second, sortStatus.columnAccessor) * multiplier, + ); + }, [containers, sortStatus]); + + const totals = useMemo( + () => + containers.reduce( + (acc, container) => { + acc.cpu += safeValue(container.cpuUsage); + acc.memory += safeValue(container.memoryUsage); + return acc; + }, + { cpu: 0, memory: 0 }, + ), + [containers], + ); + + const selectedColumns = useMemo(() => new Set(options.columns), [options.columns]); + const columns = useMemo(() => { + const sortingEnabled = options.enableRowSorting && !isEditMode; + return createColumns(t, actionHandlers, sortingEnabled).filter(({ accessor }) => + selectedColumns.has(String(accessor)), + ); + }, [actionHandlers, isEditMode, options.enableRowSorting, selectedColumns, t]); + const visibleAccessors = useMemo(() => columns.map(({ accessor }) => String(accessor)), [columns]); + const visibleAccessorSet = useMemo(() => new Set(visibleAccessors), [visibleAccessors]); + const restoredOrder = useMemo( + () => [ + ...savedOrder.filter((accessor) => visibleAccessorSet.has(accessor)), + ...visibleAccessors.filter((accessor) => !savedOrder.includes(accessor)), + ], + [savedOrder, visibleAccessors, visibleAccessorSet], + ); + const storeKey = `docker-containers-${itemId ?? "preview"}-${[...visibleAccessors].toSorted().join(",")}`; + const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = + useDataTableColumns({ + key: storeKey, + columns, + }); + + const lastStoreKey = useRef(storeKey); + const hydrated = useRef(false); + useEffect(() => { + if (lastStoreKey.current !== storeKey) { + lastStoreKey.current = storeKey; + hydrated.current = false; + } + if (hydrated.current) return; + + if (restoredOrder.length > 0) setColumnsOrder(restoredOrder); + const visibleWidths = Object.entries(savedWidths) + .filter(([accessor]) => visibleAccessorSet.has(accessor)) + .map(([accessor, columnWidth]) => ({ accessor, width: columnWidth })); + if (visibleWidths.length > 0) setMultipleColumnWidths(visibleWidths); + + requestAnimationFrame(() => { + hydrated.current = true; + }); + }, [restoredOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths, storeKey, visibleAccessorSet]); + + const previousOrder = useRef(columnsOrder); + const previousWidths = useRef(columnsWidth); + useEffect(() => { + if (!hydrated.current) return; + + const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(previousOrder.current); + const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(previousWidths.current); + previousOrder.current = columnsOrder; + previousWidths.current = columnsWidth; + if (!orderChanged && !widthsChanged) return; + + const newLayout: Partial> = {}; + if (orderChanged) { + const fullOrder = [...savedOrder, ...columnAccessors.filter((accessor) => !savedOrder.includes(accessor))]; + const reorderedVisibleColumns = [...columnsOrder]; + const mergedOrder = fullOrder.map((accessor) => + visibleAccessorSet.has(accessor) ? (reorderedVisibleColumns.shift() ?? accessor) : accessor, + ); + newLayout.columnOrder = JSON.stringify(mergedOrder); + } + if (widthsChanged) { + const widthMap: Record = { ...savedWidths }; + for (const entry of columnsWidth) { + const accessor = Object.keys(entry)[0]; + if (!accessor || !visibleAccessorSet.has(accessor)) continue; + const columnWidth = entry[accessor as keyof typeof entry]; + if (typeof columnWidth === "number" && Number.isFinite(columnWidth) && columnWidth > 0) { + widthMap[accessor] = columnWidth; + } else if (typeof columnWidth === "string" && columnWidth.endsWith("px")) { + const parsedWidth = Number.parseInt(columnWidth, 10); + if (Number.isFinite(parsedWidth) && parsedWidth > 0) widthMap[accessor] = parsedWidth; + } + } + newLayout.columnWidths = JSON.stringify(widthMap); + } + persistLayout(newLayout); + }, [columnsOrder, columnsWidth, persistLayout, savedOrder, savedWidths, visibleAccessorSet]); + + const [contextMenu, setContextMenu] = useState(null); + const closeContextMenu = useCallback(() => setContextMenu(null), []); + const handleContextMenu = useCallback(({ record, event }: { record: DockerContainer; event: React.MouseEvent }) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, container: record }); + }, []); + + if (options.columns.length === 0) { return (
{t("error.noColumns")}
); + } return ( - - + + + ({ padding: width < 400 ? "2px 8px" : "4px 8px" }), + }} + scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} + onRowContextMenu={isEditMode ? undefined : handleContextMenu} + onScroll={() => { + if (contextMenu) closeContextMenu(); + }} + /> + {!isTiny && ( - + - - {t("table.footer", { count: totalContainers.toString() })} + + {t("table.footer", { count: containers.length.toString() })} - + {t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })} - {t("table.totalMemory", { memory: formatBytes(totals.memory) })} - void refetch()} aria-label={t("table.refresh.lastUpdated", { when: relativeTime })} > - + )} + + {contextMenu && } ); } + +function ContainerMenuButton({ + container, + handlers, +}: { + container: DockerContainer; + handlers: ContainerActionHandlers; +}) { + const t = useScopedI18n("docker.action"); + const [opened, setOpened] = useState(false); + const close = () => setOpened(false); + + return ( + + + event.stopPropagation()} + > + + + + + + + + ); +} + +function ContainerActionItems({ + container, + handlers, + onClose, +}: { + container: DockerContainer; + handlers: ContainerActionHandlers; + onClose: () => void; +}) { + const t = useScopedI18n("docker.action"); + const [confirmRemove, setConfirmRemove] = useState(false); + const stateAction = container.state === "running" ? "stop" : "start"; + const StateIcon = stateAction === "stop" ? IconPlayerStop : IconPlayerPlay; + + const invokeAction = (action: ContainerAction) => { + handlers.onAction(action, container); + onClose(); + }; + + return ( + <> + {container.name} + } + onClick={() => { + handlers.onOpenLogs(container); + onClose(); + }} + > + {t("logs.label")} + + + } + onClick={() => invokeAction(stateAction)} + > + {t(`${stateAction}.label`)} + + } onClick={() => invokeAction("restart")}> + {t("restart.label")} + + {!confirmRemove ? ( + } onClick={() => setConfirmRemove(true)}> + {t("remove.label")} + + ) : ( + } onClick={() => invokeAction("remove")}> + {t("remove.confirm")} + + )} + + } + onClick={() => { + handlers.onAddToHomarr(container); + onClose(); + }} + > + {t("addToHomarr.label")} + + + ); +} + +function ContainerContextMenu({ + state, + handlers, + onClose, +}: { + state: ContextMenuState; + handlers: ContainerActionHandlers; + onClose: () => void; +}) { + return ( + + { + event.preventDefault(); + onClose(); + }} + > + + + + + event.stopPropagation()}> + + + + + + ); +} diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts index 4a988c779..03c6e990d 100644 --- a/packages/widgets/src/docker/index.ts +++ b/packages/widgets/src/docker/index.ts @@ -27,29 +27,37 @@ export const { definition, componentLoader } = createWidgetDefinition("dockerCon icon: IconBrandDocker, queryKey: [["docker", "getContainers"]], createOptions() { - return optionsBuilder.from((factory) => ({ - columns: factory.multiSelect({ - defaultValue: [...allColumnsList], - options: allColumnsList.map((value) => ({ - value, - label: (t) => t(columnTranslationKeyMap[value]), - })), - searchable: true, + return optionsBuilder.from( + (factory) => ({ + columns: factory.multiSelect({ + defaultValue: [...allColumnsList], + options: allColumnsList.map((value) => ({ + value, + label: (t) => t(columnTranslationKeyMap[value]), + })), + searchable: true, + }), + enableRowSorting: factory.switch({ + defaultValue: false, + }), + defaultSort: factory.select({ + defaultValue: "name", + options: columnsList.map((value) => ({ + value, + label: (t) => t(`widget.dockerContainers.option.defaultSort.option.${value}`), + })), + }), + descendingDefaultSort: factory.switch({ + defaultValue: false, + }), + columnOrder: factory.text({ defaultValue: "" }), + columnWidths: factory.text({ defaultValue: "" }), }), - enableRowSorting: factory.switch({ - defaultValue: false, - }), - defaultSort: factory.select({ - defaultValue: "name", - options: columnsList.map((value) => ({ - value, - label: (t) => t(`widget.dockerContainers.option.defaultSort.option.${value}`), - })), - }), - descendingDefaultSort: factory.switch({ - defaultValue: false, - }), - })); + { + columnOrder: { shouldHide: () => true }, + columnWidths: { shouldHide: () => true }, + }, + ); }, errors: { INTERNAL_SERVER_ERROR: { diff --git a/packages/widgets/src/docker/styles.css b/packages/widgets/src/docker/styles.css new file mode 100644 index 000000000..631575dfc --- /dev/null +++ b/packages/widgets/src/docker/styles.css @@ -0,0 +1,45 @@ +/* Keep the table transparent so the board card controls the surface. */ +.docker-containers-table, +.docker-containers-table .mantine-datatable-table, +.docker-containers-table .mantine-datatable-table thead, +.docker-containers-table .mantine-datatable-table tbody, +.docker-containers-table .mantine-datatable-table tfoot, +.docker-containers-table th, +.docker-containers-table td { + background-color: transparent !important; +} + +.docker-containers-table .mantine-datatable-table tr { + background-color: transparent; +} + +.docker-containers-table .mantine-datatable-table tbody tr:hover { + background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; +} + +.docker-containers-table th { + white-space: nowrap; + background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; + backdrop-filter: blur(8px); +} + +.docker-containers-table td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.docker-containers-table td .mantine-Text-root, +.docker-containers-table td .mantine-Group-root { + white-space: nowrap; + flex-wrap: nowrap; +} + +.docker-containers-table .mantine-datatable-header-cell-sortable-icon { + opacity: 0; + transition: opacity 150ms; +} + +.docker-containers-table th:hover .mantine-datatable-header-cell-sortable-icon { + opacity: 1; +}