feat: docker add to homarr (#1825)
This commit is contained in:
@@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
import type { 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, IconRefresh, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
|
import {
|
||||||
|
IconCategoryPlus,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconRefresh,
|
||||||
|
IconRotateClockwise,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
@@ -10,6 +17,8 @@ import type { RouterOutputs } from "@homarr/api";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
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 { useModalAction } from "@homarr/modals";
|
||||||
|
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
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";
|
||||||
@@ -125,6 +134,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||||
|
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||||
return (
|
return (
|
||||||
<Group gap={"sm"}>
|
<Group gap={"sm"}>
|
||||||
{groupedAlert}
|
{groupedAlert}
|
||||||
@@ -134,7 +144,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
totalCount: table.getRowCount(),
|
totalCount: table.getRowCount(),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
|
<ContainerActionBar selectedContainers={dockerContainers} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -151,16 +161,29 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerActionBarProps {
|
interface ContainerActionBarProps {
|
||||||
selectedIds: string[];
|
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
|
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||||
|
const t = useScopedI18n("docker.action");
|
||||||
|
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||||
|
const handleClick = () => {
|
||||||
|
openModal({
|
||||||
|
selectedContainers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedIds = selectedContainers.map((container) => container.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||||
|
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
|
||||||
|
{t("addToHomarr.label")}
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -174,9 +197,10 @@ interface ContainerActionBarButtonProps {
|
|||||||
|
|
||||||
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||||
const t = useScopedI18n("docker.action");
|
const t = useScopedI18n("docker.action");
|
||||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
|
||||||
|
|
||||||
const handleClickAsync = async () => {
|
const handleClickAsync = async () => {
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{ ids: props.selectedIds },
|
{ ids: props.selectedIds },
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { asc, createId, eq, inArray, like } from "@homarr/db";
|
import { asc, createId, eq, inArray, like } from "@homarr/db";
|
||||||
import { apps } from "@homarr/db/schema";
|
import { apps } from "@homarr/db/schema";
|
||||||
import { selectAppSchema } from "@homarr/db/validationSchemas";
|
import { selectAppSchema } from "@homarr/db/validationSchemas";
|
||||||
|
import { getIconForName } from "@homarr/icons";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { canUserSeeAppAsync } from "./app/app-access-control";
|
import { canUserSeeAppAsync } from "./app/app-access-control";
|
||||||
|
|
||||||
|
const defaultIcon = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/homarr.svg";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
getPaginated: protectedProcedure
|
getPaginated: protectedProcedure
|
||||||
.input(validation.common.paginated)
|
.input(validation.common.paginated)
|
||||||
@@ -118,6 +121,21 @@ export const appRouter = createTRPCRouter({
|
|||||||
href: input.href,
|
href: input.href,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
createMany: permissionRequiredProcedure
|
||||||
|
.requiresPermission("app-create")
|
||||||
|
.input(validation.app.createMany)
|
||||||
|
.output(z.void())
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.db.insert(apps).values(
|
||||||
|
input.map((app) => ({
|
||||||
|
id: createId(),
|
||||||
|
name: app.name,
|
||||||
|
description: app.description,
|
||||||
|
iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon,
|
||||||
|
href: app.href,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
update: permissionRequiredProcedure
|
update: permissionRequiredProcedure
|
||||||
.requiresPermission("app-modify-all")
|
.requiresPermission("app-modify-all")
|
||||||
.input(convertIntersectionToZodObject(validation.app.edit))
|
.input(convertIntersectionToZodObject(validation.app.edit))
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Database } from "@homarr/db";
|
|||||||
import { like } from "@homarr/db";
|
import { like } from "@homarr/db";
|
||||||
import { icons } from "@homarr/db/schema";
|
import { icons } from "@homarr/db/schema";
|
||||||
|
|
||||||
export const getIconForNameAsync = async (db: Database, name: string) => {
|
export const getIconForName = (db: Database, name: string) => {
|
||||||
return await db.query.icons.findFirst({
|
return db.query.icons.findFirst({
|
||||||
where: like(icons.name, `%${name}%`),
|
where: like(icons.name, `%${name}%`),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Button, Group, Image, List, LoadingOverlay, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { createModal } from "@homarr/modals";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
|
interface AddDockerAppToHomarrProps {
|
||||||
|
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(({ actions, innerProps }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const form = useZodForm(
|
||||||
|
z.object({
|
||||||
|
containerUrls: z.array(z.string().url().nullable()),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
initialValues: {
|
||||||
|
containerUrls: innerProps.selectedContainers.map((container) => {
|
||||||
|
if (container.ports[0]) {
|
||||||
|
return `http://${container.ports[0].IP}:${container.ports[0].PublicPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { mutate, isPending } = clientApi.app.createMany.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
actions.closeModal();
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("docker.action.addToHomarr.notification.success.title"),
|
||||||
|
message: t("docker.action.addToHomarr.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("docker.action.addToHomarr.notification.error.title"),
|
||||||
|
message: t("docker.action.addToHomarr.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handleSubmit = () => {
|
||||||
|
mutate(
|
||||||
|
innerProps.selectedContainers.map((container, index) => ({
|
||||||
|
name: container.name,
|
||||||
|
iconUrl: container.iconUrl,
|
||||||
|
description: null,
|
||||||
|
href: form.values.containerUrls[index] ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||||
|
<Stack>
|
||||||
|
<List>
|
||||||
|
{innerProps.selectedContainers.map((container, index) => (
|
||||||
|
<List.Item
|
||||||
|
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
|
||||||
|
icon={<Image src={container.iconUrl} alt="container image" w={30} h={30} />}
|
||||||
|
key={container.id}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text>{container.name}</Text>
|
||||||
|
<TextInput {...form.getInputProps(`containerUrls.${index}`)} />
|
||||||
|
</Group>
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Group justify="end">
|
||||||
|
<Button onClick={actions.closeModal} variant="light">
|
||||||
|
{t("common.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!form.isValid()} type="submit">
|
||||||
|
{t("common.action.add")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle(t) {
|
||||||
|
return t("docker.action.addToHomarr.modal.title");
|
||||||
|
},
|
||||||
|
});
|
||||||
1
packages/modals-collection/src/docker/index.ts
Normal file
1
packages/modals-collection/src/docker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AddDockerAppToHomarrModal as AddDockerAppToHomarr } from "./add-docker-app-to-homarr";
|
||||||
@@ -2,3 +2,4 @@ export * from "./boards";
|
|||||||
export * from "./invites";
|
export * from "./invites";
|
||||||
export * from "./groups";
|
export * from "./groups";
|
||||||
export * from "./search-engines";
|
export * from "./search-engines";
|
||||||
|
export * from "./docker";
|
||||||
|
|||||||
@@ -2562,6 +2562,22 @@
|
|||||||
"message": "Something went wrong while refreshing the containers"
|
"message": "Something went wrong while refreshing the containers"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"addToHomarr": {
|
||||||
|
"label": "Add to Homarr",
|
||||||
|
"notification": {
|
||||||
|
"success": {
|
||||||
|
"title": "Added to Homarr",
|
||||||
|
"message": "Selected apps have been added to Homarr"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Could not add to Homarr",
|
||||||
|
"message": "Selected apps could not be added to Homarr"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"title": "Add docker container(-s) to Homarr"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|||||||
@@ -11,5 +11,8 @@ const editAppSchema = manageAppSchema.and(z.object({ id: z.string() }));
|
|||||||
|
|
||||||
export const appSchemas = {
|
export const appSchemas = {
|
||||||
manage: manageAppSchema,
|
manage: manageAppSchema,
|
||||||
|
createMany: z
|
||||||
|
.array(manageAppSchema.omit({ iconUrl: true }).and(z.object({ iconUrl: z.string().min(1).nullable() })))
|
||||||
|
.min(1),
|
||||||
edit: editAppSchema,
|
edit: editAppSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user