Compare commits

...
19 changed files with 1171 additions and 484 deletions

No files matched your search

@@ -17,10 +17,17 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']} categories={['System Monitoring']}
/> />
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Interactions
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
- **Open system statistics** by selecting a row
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations items={[{
@@ -40,12 +40,19 @@ import dockerTable from './img/table.png';
### Interactions ### Interactions
Each container row has an action menu (accessible via the three-dot icon) with the following options: Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
- **View logs** - opens a dedicated terminal page streaming the container's logs - **View logs** - opens a dedicated terminal page streaming the container's logs
- **Start** / **Stop** / **Restart** - manage the container's state - **Start** / **Stop** / **Restart** - manage the container's state
- **Remove** - remove the container - **Remove** - permanently remove the container after a second confirmation click
- **Add to Homarr** - create a bookmark app from the container - **Add to Homarr** - create a bookmark app from the container
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration
<WidgetConfig configuration={dockerContainersWidget.configuration} />
@@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers", path: "../../widgets/docker-containers",
configuration: { configuration: {
items: [ items: [
{
name: "Columns to show",
description: "Select which columns are visible in the table",
values: "Name, State, Host, CPU usage, Memory usage, and Actions",
defaultValue: "All columns",
},
{ {
name: "Enable items sorting", name: "Enable items sorting",
description: "Allows to sort containers by clicking on the column headers", description: "Allows to sort containers by clicking on the column headers",
+1 -1
View File
@@ -44,7 +44,7 @@ import contextMenuImg from './img/context-menu.png';
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes - **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending - **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display via the widget options panel - **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu - **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal - **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category - **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
@@ -259,7 +259,7 @@ describe("NavidromeIntegration.getCurrentSessionsAsync", () => {
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
test("throws on subsonic auth failure", async () => { test("normalizes subsonic auth failures", async () => {
mockFetch.mockImplementation((url) => { mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url); const urlStr = toUrlString(url);
+22 -2
View File
@@ -2428,10 +2428,21 @@
}, },
"descendingDefaultSort": { "descendingDefaultSort": {
"label": "Invert sorting" "label": "Invert sorting"
},
"columnOrder": {
"label": "Column order"
},
"columnWidths": {
"label": "Column widths"
} }
}, },
"error": { "error": {
"internalServerError": "Failed to fetch containers stats" "internalServerError": "Failed to fetch containers stats",
"layoutSaveTitle": "Table layout not saved",
"layoutSaveMessage": "The Docker table layout could not be saved. Try moving or resizing the column again."
},
"empty": {
"noContainers": "No containers found"
} }
}, },
"coolify": { "coolify": {
@@ -3940,11 +3951,19 @@
}, },
"showAgent": { "showAgent": {
"label": "Show agent version" "label": "Show agent version"
},
"columnOrder": {
"label": "Column order"
},
"columnWidths": {
"label": "Column widths"
} }
}, },
"noRecords": "No systems found", "noRecords": "No systems found",
"error": { "error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings." "internalServerError": "Unable to connect to Beszel. Check your integration settings.",
"layoutSaveTitle": "Unable to save table layout",
"layoutSaveMessage": "Your column order or widths could not be saved. Please try again."
}, },
"column": { "column": {
"system": "System", "system": "System",
@@ -5342,6 +5361,7 @@
}, },
"remove": { "remove": {
"label": "Remove", "label": "Remove",
"confirm": "Click again to remove permanently",
"notification": { "notification": {
"success": { "success": {
"title": "Containers removed", "title": "Containers removed",
@@ -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"; "use client";
import "./styles.css"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core"; import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import { import {
Activity, Activity,
Battery, Battery,
@@ -20,10 +17,16 @@ import {
} from "lucide-react"; } from "lucide-react";
import { clientApi } from "@homarr/api/client"; 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 { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; 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 type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { import {
@@ -43,6 +46,21 @@ const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string }; 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 { interface SizeConfig {
iconSize: number; iconSize: number;
fontSize: "xs" | "sm"; fontSize: "xs" | "sm";
@@ -75,15 +93,36 @@ export default function BeszelSystemTableWidget({
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width,
boardId,
itemId,
setOptions,
}: WidgetComponentProps<"beszelSystemTable">) { }: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable"); const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal); const { openModal } = useModalAction(BeszelSystemStatsModal);
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const { const {
data: results = [], data: results = [],
error: systemsError, error: systemsError,
isPending, isPending,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); } = 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); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
@@ -91,6 +130,12 @@ export default function BeszelSystemTableWidget({
columnAccessor: options.sortBy, columnAccessor: options.sortBy,
direction: options.sortDirection as "asc" | "desc", 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 sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow; const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
@@ -122,6 +167,8 @@ export default function BeszelSystemTableWidget({
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [ const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
{ {
accessor: "name", accessor: "name",
width: 160,
ellipsis: true,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Server size={size.iconSize} /> <Server size={size.iconSize} />
@@ -130,7 +177,7 @@ export default function BeszelSystemTableWidget({
), ),
sortable: true, sortable: true,
render: (record) => ( render: (record) => (
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
<Indicator color={statusColorMap[record.status]} size={7} /> <Indicator color={statusColorMap[record.status]} size={7} />
<Text size={size.fontSize} fw={500} truncate> <Text size={size.fontSize} fw={500} truncate>
{record.name} {record.name}
@@ -140,6 +187,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showCpu && { options.showCpu && {
accessor: "cpu", accessor: "cpu",
width: 140,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Cpu size={size.iconSize} /> <Cpu size={size.iconSize} />
@@ -151,6 +199,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showMemory && { options.showMemory && {
accessor: "memory", accessor: "memory",
width: 140,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<MemoryStick size={size.iconSize} /> <MemoryStick size={size.iconSize} />
@@ -162,6 +211,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showDisk && { options.showDisk && {
accessor: "disk", accessor: "disk",
width: 160,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<HardDrive size={size.iconSize} /> <HardDrive size={size.iconSize} />
@@ -181,6 +231,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showGpu && { options.showGpu && {
accessor: "gpu", accessor: "gpu",
width: 140,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Monitor size={size.iconSize} /> <Monitor size={size.iconSize} />
@@ -192,6 +243,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showLoadAvg && { options.showLoadAvg && {
accessor: "loadAvg", accessor: "loadAvg",
width: 100,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} /> <Activity size={size.iconSize} />
@@ -208,6 +260,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showNet && { options.showNet && {
accessor: "netBytes", accessor: "netBytes",
width: 100,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Network size={size.iconSize} /> <Network size={size.iconSize} />
@@ -223,6 +276,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showTemp && { options.showTemp && {
accessor: "temp", accessor: "temp",
width: 80,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Thermometer size={size.iconSize} /> <Thermometer size={size.iconSize} />
@@ -234,6 +288,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showBattery && { options.showBattery && {
accessor: "battery", accessor: "battery",
width: 70,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Battery size={size.iconSize} /> <Battery size={size.iconSize} />
@@ -244,6 +299,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showServices && { options.showServices && {
accessor: "services", accessor: "services",
width: 80,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Server size={size.iconSize} /> <Server size={size.iconSize} />
@@ -255,6 +311,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showUptime && { options.showUptime && {
accessor: "uptime", accessor: "uptime",
width: 90,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} /> <Activity size={size.iconSize} />
@@ -266,6 +323,7 @@ export default function BeszelSystemTableWidget({
}, },
options.showAgent && { options.showAgent && {
accessor: "agentVersion", accessor: "agentVersion",
width: 90,
title: ( title: (
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Wifi size={size.iconSize} /> <Wifi size={size.iconSize} />
@@ -278,12 +336,39 @@ export default function BeszelSystemTableWidget({
]; ];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[]; 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 { effectiveColumns, storeKey } = usePersistedTableLayout({
const integrationId = record._key.split(":")[0] ?? ""; columns,
openModal({ integrationId, systemId: record.id }, { title: record.name }); 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; if (systemsError) throw systemsError;
@@ -300,20 +385,17 @@ export default function BeszelSystemTableWidget({
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}> <div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} /> <BeszelIntegrationErrorIndicator results={results} />
</div> </div>
<DataTable <HomarrDataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }} isEditMode={isEditMode}
withTableBorder={false} cellPadding={`${size.cellPadding}px 8px`}
borderRadius={0}
highlightOnHover
fz={size.fontSize} fz={size.fontSize}
records={sortedSystems} records={sortedSystems}
columns={columns} columns={effectiveColumns}
sortStatus={sortStatus} sortStatus={sortStatus}
onSortStatusChange={setSortStatus} onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")} noRecordsText={t("noRecords")}
idAccessor="_key" idAccessor="_key"
height="100%" storeColumnsKey={storeKey}
className="beszel-table"
onRowClick={isEditMode ? undefined : handleRowClick} onRowClick={isEditMode ? undefined : handleRowClick}
/> />
</div> </div>
@@ -29,28 +29,36 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
supportedIntegrations: ["beszel", "mock"], supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from(
statusFilter: factory.select({ (factory) => ({
defaultValue: "all", statusFilter: factory.select({
options: statusOptions, 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({ columnOrder: { shouldHide: () => true },
defaultValue: "asc", columnWidths: { shouldHide: () => true },
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 }),
}));
}, },
errors: { errors: {
INTERNAL_SERVER_ERROR: { 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"; "use client";
import { useMemo } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react"; import {
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise } from "@tabler/icons-react"; IconBrandDocker,
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table"; IconCategoryPlus,
import { MantineReactTable } from "mantine-react-table"; 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 type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; 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 { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker"; import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; 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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition"; 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 ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option"); 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 = ( const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>, t: ReturnType<typeof useScopedI18n<"docker">>,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [ handlers: ContainerActionHandlers,
sortingEnabled: boolean,
): DataTableColumn<DockerContainer>[] => [
{ {
id: "name", accessor: "name",
accessorKey: "name", title: t("field.name.label"),
header: t("field.name.label"), width: 180,
Cell({ renderedCellValue, row }) { ellipsis: true,
return ( sortable: sortingEnabled,
<Group gap="xs" wrap="nowrap"> render: (container) => (
<Avatar variant="outline" size={20} src={row.original.iconUrl} /> <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<Text p="0.5" size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis" }}> <Avatar
{renderedCellValue} variant="outline"
</Text> radius="sm"
</Group> size={20}
); styles={{ image: { objectFit: "contain" } }}
}, src={container.iconUrl}
}, style={{ flexShrink: 0 }}
{ >
id: "state", {container.name.at(0)?.toUpperCase()}
accessorKey: "state", </Avatar>
size: 100, <Text size="sm" truncate>
header: t("field.state.label"), {container.name}
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}
</Text> </Text>
); </Group>
}, ),
}, },
{ {
id: "cpuUsage", accessor: "state",
sortingFn: (rowA, rowB) => { title: t("field.state.label"),
const cpuUsageA = safeValue(rowA.original.cpuUsage); width: 100,
const cpuUsageB = safeValue(rowB.original.cpuUsage); sortable: sortingEnabled,
render: (container) => <ContainerStateBadge state={container.state} />,
return cpuUsageA - cpuUsageB; },
}, {
accessorKey: "cpuUsage", accessor: "host",
size: 80, title: t("field.host.label"),
header: t("field.stats.cpu.label"), width: 120,
Cell({ row }) { sortable: sortingEnabled,
const cpuUsage = safeValue(row.original.cpuUsage); 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 ( return (
<Text size="xs" c={cpuUsageColor(cpuUsage, row.original.state)}> <Text size="xs" c={cpuUsageColor(cpuUsage, container.state)}>
{cpuUsage.toFixed(2)}% {cpuUsage.toFixed(2)}%
</Text> </Text>
); );
}, },
}, },
{ {
id: "memoryUsage", accessor: "memoryUsage",
sortingFn: (rowA, rowB) => { title: t("field.stats.memory.label"),
const memoryUsageA = safeValue(rowA.original.memoryUsage); width: 100,
const memoryUsageB = safeValue(rowB.original.memoryUsage); sortable: sortingEnabled,
render: (container) => {
return memoryUsageA - memoryUsageB; const memoryUsage = safeValue(container.memoryUsage);
},
accessorKey: "memoryUsage",
size: 80,
header: t("field.stats.memory.label"),
Cell({ row }) {
const bytesUsage = safeValue(row.original.memoryUsage);
return ( return (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}> <Text size="xs" c={memoryUsageColor(memoryUsage, container.state)}>
{formatBytes(bytesUsage)} {formatBytes(memoryUsage)}
</Text> </Text>
); );
}, },
}, },
{ {
id: "actions", accessor: "actions",
accessorKey: "actions", title: "",
size: 80, width: 44,
header: t("action.title"), textAlign: "right",
enableSorting: false, render: (container) => <ContainerMenuButton container={container} handlers={handlers} />,
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>
);
},
}, },
]; ];
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 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 isTiny = width <= 256;
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery(); 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 timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(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(() => { const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
return containers.reduce( onError: () =>
(acc, container) => { showErrorNotification({
acc.cpu += safeValue(container.cpuUsage); title: tWidget("error.layoutSaveTitle"),
acc.memory += safeValue(container.memoryUsage); message: tWidget("error.layoutSaveMessage"),
return acc; }),
}, });
{ cpu: 0, memory: 0 }, const persistLayout = useTableLayoutPersistence({
); boardId,
}, [containers]); hasChangeAccess,
itemId,
const columns = useMemo(() => createColumns(t), [t]); saveItemOptions,
setOptions,
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%",
},
},
}); });
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 ( return (
<Center h="100%"> <Center h="100%">
<Text>{t("error.noColumns")}</Text> <Text>{t("error.noColumns")}</Text>
</Center> </Center>
); );
}
return ( return (
<Stack gap={0} h="100%" display="flex"> <Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<MantineReactTable table={table} /> <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 && ( {!isTiny && (
<Group <Group justify="space-between" style={{ borderTop: "0.0625rem solid var(--border-color)" }} p={4} wrap="nowrap">
justify="space-between"
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<IconBrandDocker size={20} style={{ flexShrink: 0 }} /> <IconBrandDocker size={20} style={{ flexShrink: 0 }} />
<Text size="sm" truncate="end"> <Text size="sm" truncate>
{t("table.footer", { count: totalContainers.toString() })} {t("table.footer", { count: containers.length.toString() })}
</Text> </Text>
</Group> </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" }}> <Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })} {t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text> </Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}> <Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })} {t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text> </Text>
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}> <Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon <ActionIcon
size="sm" size="sm"
@@ -307,12 +359,161 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
onClick={() => void refetch()} onClick={() => void refetch()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })} aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
> >
<IconRefresh style={actionIconIconStyle} /> <IconRefresh size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </Group>
</Group> </Group>
)} )}
{contextMenu && <ContainerContextMenu state={contextMenu} handlers={actionHandlers} onClose={closeContextMenu} />}
</Stack> </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, icon: IconBrandDocker,
queryKey: [["docker", "getContainers"]], queryKey: [["docker", "getContainers"]],
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from(
columns: factory.multiSelect({ (factory) => ({
defaultValue: [...allColumnsList], columns: factory.multiSelect({
options: allColumnsList.map((value) => ({ defaultValue: [...allColumnsList],
value, options: allColumnsList.map((value) => ({
label: (t) => t(columnTranslationKeyMap[value]), value,
})), label: (t) => t(columnTranslationKeyMap[value]),
searchable: true, })),
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, columnOrder: { shouldHide: () => true },
}), columnWidths: { shouldHide: () => true },
defaultSort: factory.select({ },
defaultValue: "name", );
options: columnsList.map((value) => ({
value,
label: (t) => t(`widget.dockerContainers.option.defaultSort.option.${value}`),
})),
}),
descendingDefaultSort: factory.switch({
defaultValue: false,
}),
}));
}, },
errors: { errors: {
INTERNAL_SERVER_ERROR: { INTERNAL_SERVER_ERROR: {
+45 -94
View File
@@ -3,7 +3,7 @@
import "../widgets-common.css"; import "../widgets-common.css";
import "./styles.css"; import "./styles.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
ActionIcon, ActionIcon,
Avatar, Avatar,
@@ -41,10 +41,11 @@ import {
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable, useDataTableColumns } from "mantine-datatable";
import { clientApi } from "@homarr/api/client"; 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 { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations"; import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
@@ -52,6 +53,8 @@ import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@@ -162,14 +165,23 @@ function progressColor(state: DownloadState, progress: number): string {
return "blue"; return "blue";
} }
function parseJsonOption<T>(value: string | undefined, fallback: T): T { const columnAccessors = [
if (!value) return fallback; "name",
try { "progress",
return JSON.parse(value) as T; "size",
} catch { "downSpeed",
return fallback; "upSpeed",
} "time",
} "state",
"added",
"ratio",
"received",
"sent",
"category",
"integration",
"index",
"type",
] as const;
function toCategoryArray(category: string | string[]): string[] { function toCategoryArray(category: string | string[]): string[] {
if (Array.isArray(category)) return category; if (Array.isArray(category)) return category;
@@ -288,6 +300,9 @@ export default function DownloadClientsWidget({
width, width,
}: WidgetComponentProps<"downloads">) { }: WidgetComponentProps<"downloads">) {
const allInteractAccess = useIntegrationsWithInteractAccess(); const allInteractAccess = useIntegrationsWithInteractAccess();
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const integrationInteractSet = useMemo( const integrationInteractSet = useMemo(
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)), () => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
[allInteractAccess, integrationIds], [allInteractAccess, integrationIds],
@@ -323,19 +338,13 @@ export default function DownloadClientsWidget({
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({ const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
}); });
const persistOption = useCallback( const persistOption = useTableLayoutPersistence({
(newOptions: Partial<Record<string, string>>) => { boardId,
setOptions({ newOptions }); hasChangeAccess,
if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions }); itemId,
}, saveItemOptions,
[setOptions, boardId, itemId, saveItemOptions], setOptions,
); });
const savedOrder = useMemo(() => parseJsonOption(options.columnOrder, [] as string[]), [options.columnOrder]);
const savedWidths = useMemo(
() => parseJsonOption(options.columnWidths, {} as Record<string, number>),
[options.columnWidths],
);
let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc"; let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc";
if (options.descendingDefaultSort) defaultSortDirection = "desc"; if (options.descendingDefaultSort) defaultSortDirection = "desc";
@@ -659,54 +668,15 @@ export default function DownloadClientsWidget({
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[]; return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
}, [columnContext, t, size, progressColumnWidth]); }, [columnContext, t, size, progressColumnWidth]);
const storeKey = `downloads-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`; const { effectiveColumns, storeKey } = usePersistedTableLayout({
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = columns,
useDataTableColumns<ExtendedDownloadClientItem>({ columnAccessors,
key: storeKey, columnOrder: options.columnOrder,
columns, columnWidths: options.columnWidths,
}); itemId,
storeKeyPrefix: "downloads",
const lastStoreKey = useRef(storeKey); onLayoutChange: persistOption,
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 handleContextMenu = useCallback( const handleContextMenu = useCallback(
({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => { ({ 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; let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined; if (isEditMode) rowContextMenuHandler = undefined;
@@ -781,35 +748,19 @@ export default function DownloadClientsWidget({
)} )}
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}> <Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
<DataTable <HomarrDataTable
style={{ pointerEvents: tablePointerEvents }} isEditMode={isEditMode}
withTableBorder={false} cellPadding={`${size.cellPadding}px 8px`}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
fetching={isFetching && currentItems.length === 0} fetching={isFetching && currentItems.length === 0}
loaderBackgroundBlur={2}
fz={size.fontSize} fz={size.fontSize}
records={sortedData} records={sortedData}
columns={effectiveColumns} columns={effectiveColumns}
storeColumnsKey={storeKey} storeColumnsKey={storeKey}
textSelectionDisabled
sortStatus={sortStatus} sortStatus={sortStatus}
onSortStatusChange={setSortStatus} onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noItems")} noRecordsText={t("errors.noItems")}
idAccessor={(record) => `${record.integration.id}:${record.id}`} idAccessor={(record) => `${record.integration.id}:${record.id}`}
height="100%"
className="downloads-table" 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 })} customRowAttributes={(_, index) => ({ "data-row-index": index })}
onRowContextMenu={rowContextMenuHandler} onRowContextMenu={rowContextMenuHandler}
rowBackgroundColor={(record) => rowBgAlpha[record.state]} 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 */ /* Row expansion styling */
.downloads-table .mantine-datatable-row-expansion-cell { .downloads-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important; background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;