Merge origin/dev into feat/build-performance

This commit is contained in:
Thomas Camlong committed 2026-08-03 15:21:23 +02:00
commit 08af704c8a
106 files changed
+28003 -22245

No files matched your search

@@ -42,7 +42,6 @@ describe("getVisibleStats", () => {
artistCount: 100,
albumCount: 200,
songCount: 3000,
nowPlaying: [],
};
const audiobookshelfStats: AudiobookshelfDashboardData = {
@@ -87,92 +87,6 @@
font-size: clamp(11px, 4cqi, 16px);
}
.nowPlaying {
display: flex;
flex-direction: column;
gap: clamp(3px, 1.2cqi, 5px);
flex: 1;
min-height: 0;
overflow: hidden;
}
.nowPlayingHeader {
display: flex;
align-items: center;
gap: clamp(4px, 1.5cqi, 6px);
font-size: clamp(10px, 2.8cqi, 12px);
font-weight: 600;
flex-shrink: 0;
min-width: 0;
}
.nowPlayingTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nowPlayingCount {
flex-shrink: 0;
margin-inline-start: auto;
}
.nowPlayingList {
display: flex;
flex-direction: column;
gap: clamp(2px, 0.8cqi, 4px);
flex: 1;
min-height: 0;
overflow: hidden;
}
.nowPlayingItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(4px, 1.5cqi, 6px);
padding: clamp(3px, 1.2cqi, 6px) clamp(4px, 1.5cqi, 8px);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
min-width: 0;
flex-shrink: 0;
}
.trackInfo {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.trackTitle {
font-size: clamp(9px, 2.6cqi, 12px);
font-weight: 500;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackArtist {
font-size: clamp(8px, 2.2cqi, 10px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackUser {
flex-shrink: 0;
max-width: clamp(40px, 22cqi, 88px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
display: flex;
flex: 1;
@@ -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;
}
@@ -0,0 +1,52 @@
.homarr-data-table,
.homarr-data-table .mantine-datatable-table,
.homarr-data-table .mantine-datatable-table thead,
.homarr-data-table .mantine-datatable-table tbody,
.homarr-data-table .mantine-datatable-table tfoot,
.homarr-data-table th,
.homarr-data-table td {
background-color: transparent !important;
}
.homarr-data-table .mantine-datatable-table tr {
background-color: transparent;
}
.homarr-data-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.homarr-data-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.homarr-data-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.homarr-data-table td .mantine-Text-root,
.homarr-data-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
opacity: 0;
transition: opacity 150ms ease-out;
}
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
.homarr-data-table .mantine-datatable-header-cell-drag-over .mantine-datatable-header-cell-draggable-action-icon {
opacity: 1;
}
.homarr-data-table-row-cursor-default .mantine-datatable-context-menu-cursor {
cursor: default;
}
@@ -0,0 +1,57 @@
"use client";
import "./homarr-data-table.css";
import type { DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
type HomarrDataTableProps<T> = DataTableProps<T> & {
isEditMode: boolean;
cellPadding: string;
rowCursor?: "auto" | "default";
};
export function HomarrDataTable<T>({
isEditMode,
cellPadding,
rowCursor = "auto",
className,
defaultColumnProps,
scrollAreaProps,
style,
...props
}: HomarrDataTableProps<T>) {
const mergedClassName = [
"homarr-data-table",
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
className,
]
.filter(Boolean)
.join(" ");
return (
<DataTable<T>
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
loaderBackgroundBlur={2}
textSelectionDisabled
height="100%"
{...props}
className={mergedClassName}
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
defaultColumnProps={{
...defaultColumnProps,
noWrap: defaultColumnProps?.noWrap ?? true,
draggable: !isEditMode,
resizable: !isEditMode,
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
/>
);
}
@@ -0,0 +1,83 @@
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type { Root } from "react-dom/client";
import type { DataTableColumn } from "mantine-datatable";
const tableState = vi.hoisted(() => ({
columnsOrder: ["name", "state"],
columnsWidth: [] as Record<string, string | number>[],
}));
vi.mock("mantine-datatable", () => ({
useDataTableColumns: ({ columns }: { columns: DataTableColumn<unknown>[] }) => ({
effectiveColumns: columns,
columnsOrder: tableState.columnsOrder,
columnsWidth: tableState.columnsWidth,
setColumnsOrder: vi.fn(),
setMultipleColumnWidths: vi.fn(),
}),
}));
import { usePersistedTableLayout } from "./use-persisted-table-layout";
const columns: DataTableColumn<{ name: string; state: string }>[] = [{ accessor: "name" }, { accessor: "state" }];
const columnAccessors = ["name", "state"] as const;
const LayoutHarness = ({ itemId, onLayoutChange }: { itemId: string; onLayoutChange: (layout: unknown) => void }) => {
usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: "",
columnWidths: "",
itemId,
storeKeyPrefix: "test-table",
onLayoutChange,
});
return null;
};
describe("usePersistedTableLayout", () => {
let container: HTMLDivElement;
let root: Root;
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
});
beforeEach(() => {
vi.useFakeTimers();
tableState.columnsOrder = ["name", "state"];
tableState.columnsWidth = [];
container = document.createElement("div");
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
vi.useRealTimers();
});
test("flushes a pending layout through the callback for its originating item", async () => {
const firstItemLayoutChange = vi.fn();
const secondItemLayoutChange = vi.fn();
await act(async () => {
root.render(createElement(LayoutHarness, { itemId: "first", onLayoutChange: firstItemLayoutChange }));
});
tableState.columnsOrder = ["state", "name"];
await act(async () => {
root.render(createElement(LayoutHarness, { itemId: "first", onLayoutChange: firstItemLayoutChange }));
});
expect(firstItemLayoutChange).not.toHaveBeenCalled();
await act(async () => {
root.render(createElement(LayoutHarness, { itemId: "second", onLayoutChange: secondItemLayoutChange }));
});
expect(firstItemLayoutChange).toHaveBeenCalledWith({ columnOrder: JSON.stringify(["state", "name"]) });
expect(secondItemLayoutChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,233 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { DataTableColumn } from "mantine-datatable";
import { useDataTableColumns } from "mantine-datatable";
interface TableLayoutOptions extends Record<string, unknown> {
columnOrder?: string;
columnWidths?: string;
}
interface PendingTableLayout {
layout: TableLayoutOptions;
onLayoutChange: (layout: TableLayoutOptions) => void;
storeKey: string;
}
interface UseTableLayoutPersistenceProps {
boardId: string | undefined;
hasChangeAccess: boolean;
itemId: string | undefined;
saveItemOptions: (input: { boardId: string; itemId: string; newOptions: TableLayoutOptions }) => void;
setOptions: (input: { newOptions: TableLayoutOptions }) => void;
}
interface UsePersistedTableLayoutProps<T> {
columns: DataTableColumn<T>[];
columnAccessors: readonly string[];
columnOrder: string;
columnWidths: string;
itemId: string | undefined;
storeKeyPrefix: string;
onLayoutChange: (layout: TableLayoutOptions) => void;
}
const layoutSaveDelay = 350;
export const useTableLayoutPersistence = ({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
}: UseTableLayoutPersistenceProps) =>
useCallback(
(newOptions: TableLayoutOptions) => {
setOptions({ newOptions });
if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
},
[boardId, hasChangeAccess, itemId, saveItemOptions, setOptions],
);
const toPixelWidth = (width: string | number): number | undefined => {
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
if (typeof width !== "string" || !width.endsWith("px")) return undefined;
const parsedWidth = Number.parseInt(width, 10);
return Number.isFinite(parsedWidth) && parsedWidth > 0 ? parsedWidth : undefined;
};
const getColumnWidthMap = (
columnsWidth: Record<string, string | number>[],
visibleAccessorSet: ReadonlySet<string>,
): Record<string, number> => {
const widths: Record<string, number> = {};
for (const entry of columnsWidth) {
for (const [accessor, width] of Object.entries(entry)) {
if (!visibleAccessorSet.has(accessor)) continue;
const pixelWidth = toPixelWidth(width);
if (pixelWidth !== undefined) widths[accessor] = pixelWidth;
}
}
return widths;
};
export const parseColumnOrder = (value: string, columnAccessors: readonly 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, columnAccessors: readonly string[]): Record<string, number> => {
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 const usePersistedTableLayout = <T>({
columns,
columnAccessors,
columnOrder,
columnWidths,
itemId,
storeKeyPrefix,
onLayoutChange,
}: UsePersistedTableLayoutProps<T>) => {
const savedOrder = useMemo(() => parseColumnOrder(columnOrder, columnAccessors), [columnAccessors, columnOrder]);
const savedWidths = useMemo(() => parseColumnWidths(columnWidths, columnAccessors), [columnAccessors, columnWidths]);
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 = `${storeKeyPrefix}-${itemId ?? "preview"}-${[...visibleAccessors].toSorted().join(",")}`;
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<T>({
key: storeKey,
columns,
});
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
const previousOrder = useRef(columnsOrder);
const previousWidths = useRef(columnsWidth);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
const orderMatches =
columnsOrder.length === restoredOrder.length &&
columnsOrder.every((accessor, index) => accessor === restoredOrder[index]);
const currentWidths = getColumnWidthMap(columnsWidth, visibleAccessorSet);
const visibleWidths = Object.entries(savedWidths).filter(([accessor]) => visibleAccessorSet.has(accessor));
const widthsMatch = visibleWidths.every(([accessor, width]) => currentWidths[accessor] === width);
if (orderMatches && widthsMatch) {
previousOrder.current = columnsOrder;
previousWidths.current = columnsWidth;
hydrated.current = true;
return;
}
if (!orderMatches) setColumnsOrder(restoredOrder);
if (!widthsMatch) {
setMultipleColumnWidths(visibleWidths.map(([accessor, width]) => ({ accessor, width })));
}
}, [
columnsOrder,
columnsWidth,
restoredOrder,
savedWidths,
setColumnsOrder,
setMultipleColumnWidths,
storeKey,
visibleAccessorSet,
]);
const pendingLayout = useRef<PendingTableLayout | undefined>(undefined);
const saveTimeout = useRef<number | undefined>(undefined);
const flushPendingLayout = useCallback(() => {
const pending = pendingLayout.current;
pendingLayout.current = undefined;
window.clearTimeout(saveTimeout.current);
saveTimeout.current = undefined;
if (pending) pending.onLayoutChange(pending.layout);
}, []);
useEffect(() => {
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
}, [flushPendingLayout, storeKey]);
useEffect(() => () => flushPendingLayout(), [flushPendingLayout]);
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: TableLayoutOptions = {};
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<string, number> = { ...savedWidths };
Object.assign(widthMap, getColumnWidthMap(columnsWidth, visibleAccessorSet));
newLayout.columnWidths = JSON.stringify(widthMap);
}
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
pendingLayout.current = pendingLayout.current
? { ...pendingLayout.current, layout: { ...pendingLayout.current.layout, ...newLayout } }
: { layout: newLayout, onLayoutChange, storeKey };
window.clearTimeout(saveTimeout.current);
saveTimeout.current = window.setTimeout(flushPendingLayout, layoutSaveDelay);
}, [
columnAccessors,
columnsOrder,
columnsWidth,
flushPendingLayout,
onLayoutChange,
savedOrder,
savedWidths,
storeKey,
visibleAccessorSet,
]);
return { effectiveColumns, storeKey };
};
@@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import { parseColumnOrder, parseColumnWidths } from "../common/use-persisted-table-layout";
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"];
describe("Docker 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(["memoryUsage", "removed", "name", "memoryUsage"]), columnAccessors),
).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,
}),
columnAccessors,
),
).toEqual({ name: 180 });
});
});
+427 -226
View File
@@ -1,22 +1,57 @@
"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 { useCallback, useEffect, useMemo, useState } from "react";
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 type { RouterOutputs } from "@homarr/api";
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, 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";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
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 containerMenuWidth = 240;
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
@@ -28,276 +63,293 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
);
};
const actionIconIconStyle: IconProps["style"] = {
height: "var(--ai-icon-size)",
width: "var(--ai-icon-size)",
};
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
handlers: ContainerActionHandlers,
sortingEnabled: boolean,
): DataTableColumn<DockerContainer>[] => [
{
id: "name",
accessorKey: "name",
header: t("field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs" wrap="nowrap">
<Avatar variant="outline" size={20} src={row.original.iconUrl} />
<Text p="0.5" size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{renderedCellValue}
</Text>
</Group>
);
},
},
{
id: "state",
accessorKey: "state",
size: 100,
header: t("field.state.label"),
Cell({ row }) {
return <ContainerStateBadge state={row.original.state} />;
},
},
{
id: "host",
accessorKey: "host",
size: 100,
header: t("field.host.label"),
Cell({ row }) {
return (
<Text size="xs" truncate="end">
{row.original.host}
accessor: "name",
title: t("field.name.label"),
width: 180,
ellipsis: true,
sortable: sortingEnabled,
render: (container) => (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<Avatar
variant="outline"
radius="sm"
size={20}
styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl}
style={{ flexShrink: 0 }}
>
{container.name.at(0)?.toUpperCase()}
</Avatar>
<Text size="sm" truncate>
{container.name}
</Text>
);
},
</Group>
),
},
{
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) => <ContainerStateBadge state={container.state} />,
},
{
accessor: "host",
title: t("field.host.label"),
width: 120,
sortable: sortingEnabled,
ellipsis: true,
render: (container) => (
<Text size="xs" truncate title={container.host}>
{container.host}
</Text>
),
},
{
accessor: "cpuUsage",
title: t("field.stats.cpu.label"),
width: 80,
sortable: sortingEnabled,
render: (container) => {
const cpuUsage = safeValue(container.cpuUsage);
return (
<Text size="xs" c={cpuUsageColor(cpuUsage, row.original.state)}>
<Text size="xs" c={cpuUsageColor(cpuUsage, container.state)}>
{cpuUsage.toFixed(2)}%
</Text>
);
},
},
{
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 (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
{formatBytes(bytesUsage)}
<Text size="xs" c={memoryUsageColor(memoryUsage, container.state)}>
{formatBytes(memoryUsage)}
</Text>
);
},
},
{
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 (
<Group wrap="nowrap" gap="xs">
<Tooltip label={row.original.state === "running" ? t("action.stop.label") : t("action.start.label")}>
<ActionIcon
variant="subtle"
size="xs"
radius="100%"
onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")}
>
{row.original.state === "running" ? (
<IconPlayerStop style={actionIconIconStyle} />
) : (
<IconPlayerPlay style={actionIconIconStyle} />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("action.restart.label")}>
<ActionIcon variant="subtle" size="xs" radius="100%" onClick={() => handleActionAsync("restart")}>
<IconRotateClockwise style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
</Group>
);
},
accessor: "actions",
title: "",
width: 44,
textAlign: "right",
render: (container) => <ContainerMenuButton container={container} handlers={handlers} />,
},
];
export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) {
const compareContainers = (
first: DockerContainer,
second: DockerContainer,
accessor: DataTableSortStatus<DockerContainer>["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 { openModal } = useModalAction(AddDockerAppToHomarr);
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
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) => window.location.assign(createContainerLogsPath(container)),
[],
);
const handleAddToHomarr = useCallback(
(container: DockerContainer) => openModal({ selectedContainers: [container] }),
[openModal],
);
const actionHandlers = useMemo<ContainerActionHandlers>(
() => ({
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 = useTableLayoutPersistence({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
});
if (options.columns.length === 0)
const defaultDirection = options.descendingDefaultSort ? "desc" : "asc";
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<DockerContainer>>({
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<string>(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 { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
storeKeyPrefix: "docker-containers",
onLayoutChange: persistLayout,
});
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(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 (
<Center h="100%">
<Text>{t("error.noColumns")}</Text>
</Center>
);
}
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<Box style={{ flex: 1, minHeight: 0 }}>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={width < 400 ? "2px 8px" : "4px 8px"}
rowCursor="default"
fetching={isFetching && containers.length === 0}
fz={width < 400 ? "xs" : "sm"}
records={sortedContainers}
noRecordsText={tWidget("empty.noContainers")}
columns={effectiveColumns}
storeColumnsKey={storeKey}
sortStatus={sortStatus}
onSortStatusChange={options.enableRowSorting && !isEditMode ? setSortStatus : undefined}
idAccessor="id"
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
onScroll={() => {
if (contextMenu) closeContextMenu();
}}
/>
</Box>
{!isTiny && (
<Group
justify="space-between"
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
>
<Group justify="space-between" style={{ borderTop: "0.0625rem solid var(--border-color)" }} p={4} wrap="nowrap">
<Group gap={4} wrap="nowrap">
<IconBrandDocker size={20} style={{ flexShrink: 0 }} />
<Text size="sm" truncate="end">
{t("table.footer", { count: totalContainers.toString() })}
<Text size="sm" truncate>
{t("table.footer", { count: containers.length.toString() })}
</Text>
</Group>
<Group gap="sm" wrap="wrap" justify="flex-end" style={{ flex: 1, minWidth: 0 }}>
<Group gap="sm" wrap="nowrap" justify="flex-end" style={{ minWidth: 0 }}>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
size="sm"
@@ -307,12 +359,161 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
onClick={() => void refetch()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<IconRefresh style={actionIconIconStyle} />
<IconRefresh size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
)}
{contextMenu && <ContainerContextMenu state={contextMenu} handlers={actionHandlers} onClose={closeContextMenu} />}
</Stack>
);
}
function ContainerMenuButton({
container,
handlers,
}: {
container: DockerContainer;
handlers: ContainerActionHandlers;
}) {
const t = useScopedI18n("docker.action");
const [opened, setOpened] = useState(false);
const close = () => setOpened(false);
return (
<Menu opened={opened} onChange={setOpened} closeOnItemClick={false} withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("title")}
onClick={(event) => event.stopPropagation()}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
<ContainerActionItems container={container} handlers={handlers} onClose={close} />
</Menu.Dropdown>
</Menu>
);
}
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 (
<>
<Menu.Label title={container.name} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{container.name}
</Menu.Label>
<Menu.Item
leftSection={<IconFileText size={14} />}
onClick={() => {
handlers.onOpenLogs(container);
onClose();
}}
>
{t("logs.label")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color={stateAction === "start" ? "green" : "red"}
leftSection={<StateIcon size={14} />}
onClick={() => invokeAction(stateAction)}
>
{t(`${stateAction}.label`)}
</Menu.Item>
<Menu.Item color="orange" leftSection={<IconRotateClockwise size={14} />} onClick={() => invokeAction("restart")}>
{t("restart.label")}
</Menu.Item>
{!confirmRemove ? (
<Menu.Item color="red" leftSection={<IconTrash size={14} />} onClick={() => setConfirmRemove(true)}>
{t("remove.label")}
</Menu.Item>
) : (
<Menu.Item color="red" leftSection={<IconTrash size={14} />} onClick={() => invokeAction("remove")}>
{t("remove.confirm")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
leftSection={<IconCategoryPlus size={14} />}
onClick={() => {
handlers.onAddToHomarr(container);
onClose();
}}
>
{t("addToHomarr.label")}
</Menu.Item>
</>
);
}
function ContainerContextMenu({
state,
handlers,
onClose,
}: {
state: ContextMenuState;
handlers: ContainerActionHandlers;
onClose: () => void;
}) {
return (
<Portal>
<Box
pos="fixed"
top={0}
left={0}
w="100vw"
h="100vh"
style={{ zIndex: 1000 }}
onClick={onClose}
onContextMenu={(event) => {
event.preventDefault();
onClose();
}}
>
<Menu
opened
shadow="md"
position="bottom-start"
withinPortal={false}
closeOnItemClick={false}
onClose={onClose}
>
<Menu.Target>
<Box pos="fixed" style={{ left: state.x, top: state.y, width: 1, height: 1 }} />
</Menu.Target>
<Menu.Dropdown
w={containerMenuWidth}
miw={containerMenuWidth}
maw={containerMenuWidth}
onClick={(event) => event.stopPropagation()}
>
<ContainerActionItems container={state.container} handlers={handlers} onClose={onClose} />
</Menu.Dropdown>
</Menu>
</Box>
</Portal>
);
}
+30 -22
View File
@@ -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: {
+45 -94
View File
@@ -3,7 +3,7 @@
import "../widgets-common.css";
import "./styles.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Avatar,
@@ -41,10 +41,11 @@ import {
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable, useDataTableColumns } from "mantine-datatable";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
@@ -52,6 +53,8 @@ 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";
dayjs.extend(relativeTime);
@@ -162,14 +165,23 @@ function progressColor(state: DownloadState, progress: number): string {
return "blue";
}
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
const columnAccessors = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio",
"received",
"sent",
"category",
"integration",
"index",
"type",
] as const;
function toCategoryArray(category: string | string[]): string[] {
if (Array.isArray(category)) return category;
@@ -288,6 +300,9 @@ export default function DownloadClientsWidget({
width,
}: WidgetComponentProps<"downloads">) {
const allInteractAccess = useIntegrationsWithInteractAccess();
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const integrationInteractSet = useMemo(
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
[allInteractAccess, integrationIds],
@@ -323,19 +338,13 @@ export default function DownloadClientsWidget({
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
});
const persistOption = useCallback(
(newOptions: Partial<Record<string, string>>) => {
setOptions({ newOptions });
if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
},
[setOptions, boardId, itemId, saveItemOptions],
);
const savedOrder = useMemo(() => parseJsonOption(options.columnOrder, [] as string[]), [options.columnOrder]);
const savedWidths = useMemo(
() => parseJsonOption(options.columnWidths, {} as Record<string, number>),
[options.columnWidths],
);
const persistOption = useTableLayoutPersistence({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
});
let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc";
if (options.descendingDefaultSort) defaultSortDirection = "desc";
@@ -659,54 +668,15 @@ export default function DownloadClientsWidget({
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
}, [columnContext, t, size, progressColumnWidth]);
const storeKey = `downloads-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<ExtendedDownloadClientItem>({
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 (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })));
}
// ponytail: defer hydrated flag so the persist effect skips the restore echo
requestAnimationFrame(() => {
hydrated.current = true;
});
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
const prevOrder = useRef(columnsOrder);
const prevWidths = useRef(columnsWidth);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) {
const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const w = entry[key as keyof typeof entry];
if (typeof w === "number") widthMap[key] = w;
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
}
persistOption({ columnWidths: JSON.stringify(widthMap) });
}
}, [columnsOrder, columnsWidth, persistOption]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
storeKeyPrefix: "downloads",
onLayoutChange: persistOption,
});
const handleContextMenu = useCallback(
({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => {
@@ -761,9 +731,6 @@ export default function DownloadClientsWidget({
);
}
let tablePointerEvents: React.CSSProperties["pointerEvents"];
if (isEditMode) tablePointerEvents = "none";
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
@@ -781,35 +748,19 @@ export default function DownloadClientsWidget({
)}
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
<DataTable
style={{ pointerEvents: tablePointerEvents }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
fetching={isFetching && currentItems.length === 0}
loaderBackgroundBlur={2}
fz={size.fontSize}
records={sortedData}
columns={effectiveColumns}
storeColumnsKey={storeKey}
textSelectionDisabled
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noItems")}
idAccessor={(record) => `${record.integration.id}:${record.id}`}
height="100%"
className="downloads-table"
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
customRowAttributes={(_, index) => ({ "data-row-index": index })}
onRowContextMenu={rowContextMenuHandler}
rowBackgroundColor={(record) => rowBgAlpha[record.state]}
-48
View File
@@ -1,51 +1,3 @@
/* 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. */
.downloads-table,
.downloads-table .mantine-datatable-table,
.downloads-table .mantine-datatable-table thead,
.downloads-table .mantine-datatable-table tbody,
.downloads-table .mantine-datatable-table tfoot,
.downloads-table th,
.downloads-table td {
background-color: transparent !important;
}
.downloads-table .mantine-datatable-table tr {
background-color: transparent;
}
.downloads-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.downloads-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.downloads-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.downloads-table td .mantine-Text-root,
.downloads-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.downloads-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.downloads-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
/* Row expansion styling */
.downloads-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
+78 -21
View File
@@ -26,6 +26,35 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
type TranscodingDecision = NonNullable<
NonNullable<StreamSession["currentlyPlaying"]>["metadata"]
>["transcoding"];
type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transcodeAudio" | "transcoding";
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
if (!transcoding) return "directPlay";
const { isVideoDirect, isAudioDirect, containerChanged } = transcoding;
if (isVideoDirect && isAudioDirect) return containerChanged ? "directStream" : "directPlay";
if (!isVideoDirect && isAudioDirect) return "transcodeVideo";
if (isVideoDirect && !isAudioDirect) return "transcodeAudio";
return "transcoding";
}
const playbackStatusColorMap = {
directPlay: "green",
directStream: "teal",
transcodeVideo: "orange",
transcodeAudio: "yellow",
transcoding: "red",
} satisfies Record<PlaybackStatus, string>;
function formatBitrate(bitrateKbps: number | null | undefined): string | null {
if (!bitrateKbps || bitrateKbps <= 0) return null;
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
}
export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
@@ -122,23 +151,30 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ??
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container,
);
const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding);
const bitrateLabel = formatBitrate(currentlyPlaying.metadata?.bitrateKbps);
return (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={isTranscoding ? "orange" : "green"}>
{isTranscoding ? t("items.transcoding") : t("items.directPlay")}
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
{t(`items.${status}` as never)}
</Badge>
{currentlyPlaying.location && (
<Group gap={4} align="center">
{currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />}
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
{(currentlyPlaying.location ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
<Group gap={4} align="center">
{currentlyPlaying.location &&
(currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
{currentlyPlaying.location && (
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
)}
</Group>
{bitrateLabel && (
<Text size="10px" c="dimmed">
{bitrateLabel}
</Text>
)}
</Group>
)}
</Stack>
@@ -238,6 +274,12 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
};
});
const totalBitrateKbps = flatSessions.reduce(
(sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0),
0,
);
const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null;
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
@@ -246,19 +288,34 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
h={30}
px="xs"
pr="md"
justify="flex-end"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationKind} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
<Text className="media-server-name" size="sm">
{integration.integrationName}
<Group gap={4} wrap="nowrap">
<IconVideo size={16} style={{ flexShrink: 0 }} />
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
</Text>
{totalBitrateLabel && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</Group>
))}
)}
</Group>
<Group gap="xs">
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationKind} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
<Text className="media-server-name" size="sm">
{integration.integrationName}
</Text>
</Group>
))}
</Group>
</Group>
</Stack>
);
@@ -11,6 +11,8 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ
createOptions() {
return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
showBitrate: factory.switch({ defaultValue: true, withDescription: true }),
showLocation: factory.switch({ defaultValue: true, withDescription: true }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaService"),