From 95101e34ed0cc5134a7cb15fd0ab56c5f4b5a14e Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sun, 7 Jul 2024 09:58:40 +0200 Subject: [PATCH] feat: add docker actions (#752) * feat: add docker actions * chore: remove unnecessary import --- .../{DockerTable.tsx => docker-table.tsx} | 97 ++++++++++++++----- .../app/[locale]/manage/tools/docker/page.tsx | 2 +- .../api/src/router/docker/docker-router.ts | 86 +++++++++++++++- packages/translation/src/lang/en.ts | 56 ++++++++++- 4 files changed, 211 insertions(+), 30 deletions(-) rename apps/nextjs/src/app/[locale]/manage/tools/docker/{DockerTable.tsx => docker-table.tsx} (57%) diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/DockerTable.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx similarity index 57% rename from apps/nextjs/src/app/[locale]/manage/tools/docker/DockerTable.tsx rename to apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index fe56590c7..53643a93e 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/DockerTable.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -1,16 +1,19 @@ "use client"; -import type { ButtonProps, MantineColor } from "@mantine/core"; +import type { MantineColor } from "@mantine/core"; import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core"; import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react"; import type { MRT_ColumnDef } from "mantine-react-table"; import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; import { useTimeAgo } from "@homarr/common"; import type { DockerContainerState } from "@homarr/definitions"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; const createColumns = ( @@ -61,12 +64,18 @@ const createColumns = ( }, ]; -export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) { +export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) { const t = useI18n(); const tDocker = useScopedI18n("docker"); - const relativeTime = useTimeAgo(timestamp); + const { data } = clientApi.docker.getContainers.useQuery(undefined, { + initialData, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + const relativeTime = useTimeAgo(data.timestamp); const table = useMantineReactTable({ - data: containers, + data: data.containers, enableDensityToggle: false, enableColumnActions: false, enableColumnFilters: false, @@ -77,7 +86,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"][" enableBottomToolbar: false, positionGlobalFilter: "right", mantineSearchTextInputProps: { - placeholder: tDocker("table.search", { count: containers.length }), + placeholder: tDocker("table.search", { count: data.containers.length }), style: { minWidth: 300 }, autoFocus: true, }, @@ -93,13 +102,14 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"][" totalCount: table.getRowCount(), })} - + row.original.id)} /> ); }, columns: createColumns(t), }); + return ( <> {tDocker("table.updated", { when: relativeTime })} @@ -108,31 +118,70 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"][" ); } -const ContainerActionBar = () => { - const t = useScopedI18n("docker.action"); - const sharedButtonProps = { - variant: "light", - radius: "md", - } satisfies Partial; +interface ContainerActionBarProps { + selectedIds: string[]; +} +const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => { return ( - - - - + + + + ); }; +interface ContainerActionBarButtonProps { + icon: TablerIcon; + color: MantineColor; + action: "start" | "stop" | "restart" | "remove"; + selectedIds: string[]; +} + +const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => { + const t = useScopedI18n("docker.action"); + const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation(); + const utils = clientApi.useUtils(); + + const handleClickAsync = async () => { + await mutateAsync( + { ids: props.selectedIds }, + { + async onSettled() { + await utils.docker.getContainers.invalidate(); + }, + onSuccess() { + showSuccessNotification({ + title: t(`${props.action}.notification.success.title`), + message: t(`${props.action}.notification.success.message`), + }); + }, + onError() { + showErrorNotification({ + title: t(`${props.action}.notification.error.title`), + message: t(`${props.action}.notification.error.message`), + }); + }, + }, + ); + }; + + return ( + + ); +}; + const containerStates = { created: "cyan", running: "green", diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx index cfd5da570..c9054efe2 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx @@ -4,7 +4,7 @@ import { api } from "@homarr/api/server"; import { getScopedI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { DockerTable } from "./DockerTable"; +import { DockerTable } from "./docker-table"; export default async function DockerPage() { const { containers, timestamp } = await api.docker.getContainers(); diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index fd19f14ec..186d47286 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -1,11 +1,14 @@ +import { TRPCError } from "@trpc/server"; import type Docker from "dockerode"; +import type { Container } from "dockerode"; import { db, like, or } from "@homarr/db"; import { icons } from "@homarr/db/schema/sqlite"; import type { DockerContainerState } from "@homarr/definitions"; import { createCacheChannel } from "@homarr/redis"; +import { z } from "@homarr/validation"; -import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../../trpc"; import { DockerSingleton } from "./docker-singleton"; const dockerCache = createCacheChannel<{ @@ -56,8 +59,89 @@ export const dockerRouter = createTRPCRouter({ timestamp, }; }), + startAll: permissionRequiredProcedure + .requiresPermission("admin") + .input(z.object({ ids: z.array(z.string()) })) + .mutation(async ({ input }) => { + await Promise.allSettled( + input.ids.map(async (id) => { + const container = await getContainerOrThrowAsync(id); + await container.start(); + }), + ); + + await dockerCache.invalidateAsync(); + }), + stopAll: permissionRequiredProcedure + .requiresPermission("admin") + .input(z.object({ ids: z.array(z.string()) })) + .mutation(async ({ input }) => { + await Promise.allSettled( + input.ids.map(async (id) => { + const container = await getContainerOrThrowAsync(id); + await container.stop(); + }), + ); + + await dockerCache.invalidateAsync(); + }), + restartAll: permissionRequiredProcedure + .requiresPermission("admin") + .input(z.object({ ids: z.array(z.string()) })) + .mutation(async ({ input }) => { + await Promise.allSettled( + input.ids.map(async (id) => { + const container = await getContainerOrThrowAsync(id); + await container.restart(); + }), + ); + + await dockerCache.invalidateAsync(); + }), + removeAll: permissionRequiredProcedure + .requiresPermission("admin") + .input(z.object({ ids: z.array(z.string()) })) + .mutation(async ({ input }) => { + await Promise.allSettled( + input.ids.map(async (id) => { + const container = await getContainerOrThrowAsync(id); + await container.remove(); + }), + ); + + await dockerCache.invalidateAsync(); + }), }); +const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { + const container = instance.getContainer(id); + + return await new Promise((resolve) => { + container.inspect((err, data) => { + if (err || !data) { + resolve(null); + } else { + resolve(container); + } + }); + }); +}; + +const getContainerOrThrowAsync = async (id: string) => { + const dockerInstances = DockerSingleton.getInstance(); + const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id))); + const foundContainer = containers.find((container) => container) ?? null; + + if (!foundContainer) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Container not found", + }); + } + + return foundContainer; +}; + interface DockerContainer { name: string; id: string; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index ab551330b..c5e03a1bd 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -1551,10 +1551,58 @@ export default { }, }, action: { - start: "Start", - stop: "Stop", - restart: "Restart", - remove: "Remove", + start: { + label: "Start", + notification: { + success: { + title: "Containers started", + message: "The containers were started successfully", + }, + error: { + title: "Containers not started", + message: "The containers could not be started", + }, + }, + }, + stop: { + label: "Stop", + notification: { + success: { + title: "Containers stopped", + message: "The containers were stopped successfully", + }, + error: { + title: "Containers not stopped", + message: "The containers could not be stopped", + }, + }, + }, + restart: { + label: "Restart", + notification: { + success: { + title: "Containers restarted", + message: "The containers were restarted successfully", + }, + error: { + title: "Containers not restarted", + message: "The containers could not be restarted", + }, + }, + }, + remove: { + label: "Remove", + notification: { + success: { + title: "Containers removed", + message: "The containers were removed successfully", + }, + error: { + title: "Containers not removed", + message: "The containers could not be removed", + }, + }, + }, }, }, navigationStructure: {