Merge origin/dev into feat/build-performance
This commit is contained in:
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 });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user