feat: add docker actions (#752)
* feat: add docker actions * chore: remove unnecessary import
This commit is contained in:
@@ -1,16 +1,19 @@
|
|||||||
"use client";
|
"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 { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||||
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
import { IconPlayerPlay, IconPlayerStop, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useTimeAgo } from "@homarr/common";
|
import { useTimeAgo } from "@homarr/common";
|
||||||
import type { DockerContainerState } from "@homarr/definitions";
|
import type { DockerContainerState } from "@homarr/definitions";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
import { OverflowBadge } from "@homarr/ui";
|
import { OverflowBadge } from "@homarr/ui";
|
||||||
|
|
||||||
const createColumns = (
|
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 t = useI18n();
|
||||||
const tDocker = useScopedI18n("docker");
|
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({
|
const table = useMantineReactTable({
|
||||||
data: containers,
|
data: data.containers,
|
||||||
enableDensityToggle: false,
|
enableDensityToggle: false,
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
enableColumnFilters: false,
|
enableColumnFilters: false,
|
||||||
@@ -77,7 +86,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
|||||||
enableBottomToolbar: false,
|
enableBottomToolbar: false,
|
||||||
positionGlobalFilter: "right",
|
positionGlobalFilter: "right",
|
||||||
mantineSearchTextInputProps: {
|
mantineSearchTextInputProps: {
|
||||||
placeholder: tDocker("table.search", { count: containers.length }),
|
placeholder: tDocker("table.search", { count: data.containers.length }),
|
||||||
style: { minWidth: 300 },
|
style: { minWidth: 300 },
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
},
|
},
|
||||||
@@ -93,13 +102,14 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
|||||||
totalCount: table.getRowCount(),
|
totalCount: table.getRowCount(),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<ContainerActionBar />
|
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
columns: createColumns(t),
|
columns: createColumns(t),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
|
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
|
||||||
@@ -108,31 +118,70 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerActionBar = () => {
|
interface ContainerActionBarProps {
|
||||||
const t = useScopedI18n("docker.action");
|
selectedIds: string[];
|
||||||
const sharedButtonProps = {
|
}
|
||||||
variant: "light",
|
|
||||||
radius: "md",
|
|
||||||
} satisfies Partial<ButtonProps>;
|
|
||||||
|
|
||||||
|
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Button leftSection={<IconPlayerPlay />} color="green" {...sharedButtonProps}>
|
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||||
{t("start")}
|
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||||
</Button>
|
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||||
<Button leftSection={<IconPlayerStop />} color="red" {...sharedButtonProps}>
|
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||||
{t("stop")}
|
|
||||||
</Button>
|
|
||||||
<Button leftSection={<IconRotateClockwise />} color="orange" {...sharedButtonProps}>
|
|
||||||
{t("restart")}
|
|
||||||
</Button>
|
|
||||||
<Button leftSection={<IconTrash />} color="red" {...sharedButtonProps}>
|
|
||||||
{t("remove")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</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 = {
|
const containerStates = {
|
||||||
created: "cyan",
|
created: "cyan",
|
||||||
running: "green",
|
running: "green",
|
||||||
@@ -4,7 +4,7 @@ import { api } from "@homarr/api/server";
|
|||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { DockerTable } from "./DockerTable";
|
import { DockerTable } from "./docker-table";
|
||||||
|
|
||||||
export default async function DockerPage() {
|
export default async function DockerPage() {
|
||||||
const { containers, timestamp } = await api.docker.getContainers();
|
const { containers, timestamp } = await api.docker.getContainers();
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
import type Docker from "dockerode";
|
import type Docker from "dockerode";
|
||||||
|
import type { Container } from "dockerode";
|
||||||
|
|
||||||
import { db, like, or } from "@homarr/db";
|
import { db, like, or } from "@homarr/db";
|
||||||
import { icons } from "@homarr/db/schema/sqlite";
|
import { icons } from "@homarr/db/schema/sqlite";
|
||||||
import type { DockerContainerState } from "@homarr/definitions";
|
import type { DockerContainerState } from "@homarr/definitions";
|
||||||
import { createCacheChannel } from "@homarr/redis";
|
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";
|
import { DockerSingleton } from "./docker-singleton";
|
||||||
|
|
||||||
const dockerCache = createCacheChannel<{
|
const dockerCache = createCacheChannel<{
|
||||||
@@ -56,8 +59,89 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
timestamp,
|
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 {
|
interface DockerContainer {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1551,10 +1551,58 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
start: "Start",
|
start: {
|
||||||
stop: "Stop",
|
label: "Start",
|
||||||
restart: "Restart",
|
notification: {
|
||||||
remove: "Remove",
|
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: {
|
navigationStructure: {
|
||||||
|
|||||||
Reference in New Issue
Block a user