"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActionIcon, Avatar, Badge, Box, Center, getDefaultZIndex, Group, Menu, Portal, Stack, Text, Tooltip, VisuallyHidden, } 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, DockerEndpointCapability } from "@homarr/docker"; import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { useModalAction } from "@homarr/modals"; import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; import actionTargetClasses from "../common/action-target.module.css"; import { HomarrDataTable } from "../common/homarr-data-table"; import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout"; import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout"; type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number]; type DockerEndpoint = RouterOutputs["docker"]["getContainers"]["endpoints"][number]; type ContainerAction = "start" | "stop" | "restart" | "remove"; interface ContextMenuState { x: number; y: number; container: DockerContainer; } interface ContainerActionHandlers { canUse: (container: DockerContainer, capability: DockerEndpointCapability) => boolean; 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) => `/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`; const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id }); const ContainerStateBadge = ({ state }: { state: ContainerState }) => { const t = useScopedI18n("docker.field.state.option"); return ( {t(state)} ); }; const ContainerStateDot = ({ state }: { state: ContainerState }) => { const t = useScopedI18n("docker.field.state.option"); const label = t(state); return ( ); }; const createColumns = ( t: ReturnType>, handlers: ContainerActionHandlers, sortingEnabled: boolean, inlineState: boolean, ): DataTableColumn[] => [ { accessor: "name", title: t("field.name.label"), width: 180, ellipsis: true, sortable: sortingEnabled, render: (container) => ( {inlineState && } {container.name.at(0)?.toUpperCase()} {container.name} ), }, { accessor: "state", title: t("field.state.label"), width: 100, sortable: sortingEnabled, render: (container) => , }, { accessor: "host", title: t("field.host.label"), width: 120, sortable: sortingEnabled, ellipsis: true, render: (container) => ( {container.host} ), }, { accessor: "cpuUsage", title: t("field.stats.cpu.label"), width: 80, sortable: sortingEnabled, render: (container) => { const cpuUsage = safeValue(container.cpuUsage); return ( {cpuUsage.toFixed(2)}% ); }, }, { accessor: "memoryUsage", title: t("field.stats.memory.label"), width: 100, sortable: sortingEnabled, render: (container) => { const memoryUsage = safeValue(container.memoryUsage); return ( {formatBytes(memoryUsage)} ); }, }, { accessor: "actions", title: "", width: 44, textAlign: "right", render: (container) => , }, ]; const compareContainers = ( first: DockerContainer, second: DockerContainer, accessor: DataTableSortStatus["columnAccessor"], ) => { if (accessor === "cpuUsage") return safeValue(first.cpuUsage) - safeValue(second.cpuUsage); if (accessor === "memoryUsage") return safeValue(first.memoryUsage) - safeValue(second.memoryUsage); const firstValue = first[accessor as "name" | "state" | "host"]; const secondValue = second[accessor as "name" | "state" | "host"]; return String(firstValue ?? "").localeCompare(String(secondValue ?? "")); }; const useContainerAction = (action: ContainerAction) => { const t = useScopedI18n("docker.action"); const utils = clientApi.useUtils(); return clientApi.docker[`${action}All`].useMutation({ async onSettled() { await utils.docker.getContainers.invalidate(); }, onSuccess(results) { const failed = results.some((result) => !result.success); (failed ? showErrorNotification : showSuccessNotification)({ title: t(`${action}.notification.${failed ? "error" : "success"}.title`), message: t(`${action}.notification.${failed ? "error" : "success"}.message`), }); }, onError() { showErrorNotification({ title: t(`${action}.notification.error.title`), message: t(`${action}.notification.error.message`), }); }, }); }; export default function DockerWidget({ options, width, isEditMode, displayMode, boardId, itemId, setOptions, }: WidgetComponentProps<"dockerContainers">) { const t = useScopedI18n("docker"); const tWidget = useScopedI18n("widget.dockerContainers"); const { openModal } = useModalAction(AddDockerAppToHomarr); const confirmRemoval = useDockerContainerRemovalConfirmation(); const board = useOptionalBoard(); const { data: session } = useSession(); const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false; const isAdvanced = displayMode === "advanced"; const utils = clientApi.useUtils(); const containersQuery = clientApi.docker.getContainers.useQuery(); const data = getUsableWidgetQueryData(containersQuery); const { isFetching } = containersQuery; const refreshInventory = clientApi.docker.refreshInventory.useMutation({ async onSuccess() { await Promise.all([ utils.docker.getContainers.invalidate(), utils.docker.reconcileServices.invalidate(), utils.docker.getServiceHealth.invalidate(), ]); }, onError() { showErrorNotification({ title: t("action.refresh.notification.error.title"), message: t("action.refresh.notification.error.message"), }); }, }); const containers = useMemo(() => data?.containers ?? [], [data?.containers]); const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const relativeTime = useTimeAgo(timestamp); 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) => { const target = { targets: [getContainerTarget(container)] }; if (action === "start") startContainer(target); else if (action === "stop") stopContainer(target); else if (action === "restart") restartContainer(target); else confirmRemoval([container], () => removeContainer(target)); }, [confirmRemoval, 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( () => ({ canUse: (container, capability) => endpointHasCapability( data?.endpoints.find(({ id }) => id === container.endpointId), capability, ), onAction: handleContainerAction, onAddToHomarr: handleAddToHomarr, onOpenLogs: handleOpenLogs, }), [data?.endpoints, handleAddToHomarr, handleContainerAction, handleOpenLogs], ); 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, }); const defaultDirection = options.descendingDefaultSort ? "desc" : "asc"; const [sortStatus, setSortStatus] = useState>({ columnAccessor: options.defaultSort, direction: defaultDirection, }); useEffect(() => { setSortStatus({ columnAccessor: options.defaultSort, direction: defaultDirection }); }, [defaultDirection, options.defaultSort]); const sortedContainers = useMemo(() => { const multiplier = sortStatus.direction === "desc" ? -1 : 1; return [...containers].toSorted( (first, second) => compareContainers(first, second, sortStatus.columnAccessor) * multiplier, ); }, [containers, sortStatus]); const totals = useMemo( () => containers.reduce( (acc, container) => { acc.cpu += safeValue(container.cpuUsage); acc.memory += safeValue(container.memoryUsage); return acc; }, { cpu: 0, memory: 0 }, ), [containers], ); const columnVisibility = useMemo( () => getDockerColumnVisibility(options.columns, width, isAdvanced), [isAdvanced, options.columns, width], ); const inlineState = !isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state"); const columns = useMemo(() => { const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode; return createColumns(t, actionHandlers, sortingEnabled, inlineState).filter( ({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility], ); }, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t]); const { effectiveColumns, storeKey } = usePersistedTableLayout({ columns, columnAccessors, columnOrder: options.columnOrder, columnWidths: options.columnWidths, itemId, storeKeyPrefix: "docker-containers", onLayoutChange: persistLayout, }); const [contextMenu, setContextMenu] = useState(null); const closeContextMenu = useCallback(() => setContextMenu(null), []); const handleContextMenu = useCallback(({ record, event }: { record: DockerContainer; event: React.MouseEvent }) => { event.preventDefault(); setContextMenu({ x: event.clientX, y: event.clientY, container: record }); }, []); if (!isAdvanced && options.columns.length === 0) { return (
{t("error.noColumns")}
); } if (containers.length === 0 && data?.endpoints.some(({ status }) => status === "unavailable")) { return (
{tWidget("error.endpointsUnavailable")}
); } const footerVisibility = getDockerFooterVisibility(width, isAdvanced); return ( { if (contextMenu) closeContextMenu(); }} /> {footerVisibility.footer && ( {t("table.footer", { count: containers.length.toString() })} {footerVisibility.cpu && ( {t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })} )} {footerVisibility.memory && ( {t("table.totalMemory", { memory: formatBytes(totals.memory) })} )} refreshInventory.mutate()} aria-label={t("table.refresh.lastUpdated", { when: relativeTime })} > )} {contextMenu && } ); } function ContainerMenuButton({ container, handlers, }: { container: DockerContainer; handlers: ContainerActionHandlers; }) { const t = useScopedI18n("docker.action"); const [opened, setOpened] = useState(false); const close = () => setOpened(false); return ( event.stopPropagation()} > ); } function ContainerActionItems({ container, handlers, onClose, }: { container: DockerContainer; handlers: ContainerActionHandlers; onClose: () => void; }) { const t = useScopedI18n("docker.action"); const stateAction = container.state === "running" ? "stop" : "start"; const StateIcon = stateAction === "stop" ? IconPlayerStop : IconPlayerPlay; const invokeAction = (action: ContainerAction) => { handlers.onAction(action, container); onClose(); }; return ( <> {container.name} } disabled={!handlers.canUse(container, "logs")} onClick={() => { handlers.onOpenLogs(container); onClose(); }} > {t("logs.label")} } disabled={!handlers.canUse(container, "lifecycle")} onClick={() => invokeAction(stateAction)} > {t(`${stateAction}.label`)} } disabled={!handlers.canUse(container, "lifecycle")} onClick={() => invokeAction("restart")} > {t("restart.label")} } disabled={!handlers.canUse(container, "remove")} onClick={() => invokeAction("remove")} > {t("remove.label")} } onClick={() => { handlers.onAddToHomarr(container); onClose(); }} > {t("addToHomarr.label")} ); } const endpointHasCapability = (endpoint: DockerEndpoint | undefined, capability: DockerEndpointCapability) => endpoint && "capabilities" in endpoint ? endpoint.capabilities.includes(capability) : true; function ContainerContextMenu({ state, handlers, onClose, }: { state: ContextMenuState; handlers: ContainerActionHandlers; onClose: () => void; }) { return ( { event.preventDefault(); onClose(); }} > event.stopPropagation()} > ); }