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 (
- } color="green" {...sharedButtonProps}>
- {t("start")}
-
- } color="red" {...sharedButtonProps}>
- {t("stop")}
-
- } color="orange" {...sharedButtonProps}>
- {t("restart")}
-
- } color="red" {...sharedButtonProps}>
- {t("remove")}
-
+
+
+
+
);
};
+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 (
+ }
+ color={props.color}
+ onClick={handleClickAsync}
+ loading={isPending}
+ variant="light"
+ radius="md"
+ >
+ {t(`${props.action}.label`)}
+
+ );
+};
+
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: {