feat: add docker actions (#752)

* feat: add docker actions

* chore: remove unnecessary import
This commit is contained in:
Meier Lukas
2024-07-07 09:58:40 +02:00
committed by GitHub
parent 998615fc11
commit 95101e34ed
4 changed files with 211 additions and 30 deletions

View File

@@ -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(),
})}
</Text>
<ContainerActionBar />
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
</Group>
);
},
columns: createColumns(t),
});
return (
<>
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
@@ -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<ButtonProps>;
interface ContainerActionBarProps {
selectedIds: string[];
}
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
return (
<Group gap="xs">
<Button leftSection={<IconPlayerPlay />} color="green" {...sharedButtonProps}>
{t("start")}
</Button>
<Button leftSection={<IconPlayerStop />} color="red" {...sharedButtonProps}>
{t("stop")}
</Button>
<Button leftSection={<IconRotateClockwise />} color="orange" {...sharedButtonProps}>
{t("restart")}
</Button>
<Button leftSection={<IconTrash />} color="red" {...sharedButtonProps}>
{t("remove")}
</Button>
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
</Group>
);
};
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 (
<Button
leftSection={<props.icon />}
color={props.color}
onClick={handleClickAsync}
loading={isPending}
variant="light"
radius="md"
>
{t(`${props.action}.label`)}
</Button>
);
};
const containerStates = {
created: "cyan",
running: "green",

View File

@@ -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();

View File

@@ -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<Container | null>((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;

View File

@@ -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: {