From 14d2a51bac31c438386e813be16157fc323577d0 Mon Sep 17 00:00:00 2001
From: Manuel <30572287+manuel-rw@users.noreply.github.com>
Date: Wed, 1 Jan 2025 10:37:39 +0100
Subject: [PATCH] feat: docker add to homarr
---
.../manage/tools/docker/docker-table.tsx | 34 ++++++-
packages/api/src/router/app.ts | 18 ++++
packages/icons/src/auto-icon-searcher.ts | 4 +-
.../src/docker/add-docker-app-to-homarr.tsx | 91 +++++++++++++++++++
.../modals-collection/src/docker/index.ts | 1 +
packages/modals-collection/src/index.ts | 1 +
packages/translation/src/lang/en.json | 16 ++++
packages/validation/src/app.ts | 3 +
8 files changed, 161 insertions(+), 7 deletions(-)
create mode 100644 packages/modals-collection/src/docker/add-docker-app-to-homarr.tsx
create mode 100644 packages/modals-collection/src/docker/index.ts
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx
index e5ca818de..5398aaff3 100644
--- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx
@@ -2,7 +2,14 @@
import type { MantineColor } 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 { MantineReactTable } from "mantine-react-table";
@@ -10,6 +17,8 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { DockerContainerState } from "@homarr/definitions";
+import { useModalAction } from "@homarr/modals";
+import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -125,6 +134,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
+ const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return (
{groupedAlert}
@@ -134,7 +144,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
totalCount: table.getRowCount(),
})}
- row.original.id)} />
+
);
},
@@ -151,16 +161,29 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
}
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 (
+ } color={"red"} onClick={handleClick} variant="light" radius="md">
+ {t("addToHomarr.label")}
+
);
};
@@ -174,9 +197,10 @@ interface ContainerActionBarButtonProps {
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
const t = useScopedI18n("docker.action");
- const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
const utils = clientApi.useUtils();
+ const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
+
const handleClickAsync = async () => {
await mutateAsync(
{ ids: props.selectedIds },
diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts
index 5eb65804b..2d1a70ed1 100644
--- a/packages/api/src/router/app.ts
+++ b/packages/api/src/router/app.ts
@@ -3,12 +3,15 @@ import { TRPCError } from "@trpc/server";
import { asc, createId, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import { selectAppSchema } from "@homarr/db/validationSchemas";
+import { getIconForName } from "@homarr/icons";
import { validation, z } from "@homarr/validation";
import { convertIntersectionToZodObject } from "../schema-merger";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
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({
getPaginated: protectedProcedure
.input(validation.common.paginated)
@@ -118,6 +121,21 @@ export const appRouter = createTRPCRouter({
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
.requiresPermission("app-modify-all")
.input(convertIntersectionToZodObject(validation.app.edit))
diff --git a/packages/icons/src/auto-icon-searcher.ts b/packages/icons/src/auto-icon-searcher.ts
index 0eef30e5e..06f3d9b76 100644
--- a/packages/icons/src/auto-icon-searcher.ts
+++ b/packages/icons/src/auto-icon-searcher.ts
@@ -2,8 +2,8 @@ import type { Database } from "@homarr/db";
import { like } from "@homarr/db";
import { icons } from "@homarr/db/schema";
-export const getIconForNameAsync = async (db: Database, name: string) => {
- return await db.query.icons.findFirst({
+export const getIconForName = (db: Database, name: string) => {
+ return db.query.icons.findFirst({
where: like(icons.name, `%${name}%`),
});
};
diff --git a/packages/modals-collection/src/docker/add-docker-app-to-homarr.tsx b/packages/modals-collection/src/docker/add-docker-app-to-homarr.tsx
new file mode 100644
index 000000000..9a5f2d5fc
--- /dev/null
+++ b/packages/modals-collection/src/docker/add-docker-app-to-homarr.tsx
@@ -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(({ 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 (
+
+ );
+}).withOptions({
+ defaultTitle(t) {
+ return t("docker.action.addToHomarr.modal.title");
+ },
+});
diff --git a/packages/modals-collection/src/docker/index.ts b/packages/modals-collection/src/docker/index.ts
new file mode 100644
index 000000000..31b956245
--- /dev/null
+++ b/packages/modals-collection/src/docker/index.ts
@@ -0,0 +1 @@
+export { AddDockerAppToHomarrModal as AddDockerAppToHomarr } from "./add-docker-app-to-homarr";
diff --git a/packages/modals-collection/src/index.ts b/packages/modals-collection/src/index.ts
index 66672e577..30d81d892 100644
--- a/packages/modals-collection/src/index.ts
+++ b/packages/modals-collection/src/index.ts
@@ -2,3 +2,4 @@ export * from "./boards";
export * from "./invites";
export * from "./groups";
export * from "./search-engines";
+export * from "./docker";
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 11cf49ac6..4acd411c9 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -2562,6 +2562,22 @@
"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": {
diff --git a/packages/validation/src/app.ts b/packages/validation/src/app.ts
index 6ac45ac35..507c2f8bf 100644
--- a/packages/validation/src/app.ts
+++ b/packages/validation/src/app.ts
@@ -11,5 +11,8 @@ const editAppSchema = manageAppSchema.and(z.object({ id: z.string() }));
export const appSchemas = {
manage: manageAppSchema,
+ createMany: z
+ .array(manageAppSchema.omit({ iconUrl: true }).and(z.object({ iconUrl: z.string().min(1).nullable() })))
+ .min(1),
edit: editAppSchema,
};