feat(widgets): persist Beszel and Docker table layouts (#6490)
This commit is contained in:
1 parent
f4449f9581
commit
27ca058088
19 files changed
+1171
-484
No files matched your search
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user