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..4b2480d8c 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,10 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
totalCount: table.getRowCount(),
})}
- row.original.id)} />
+ container.id)}
+ />
);
},
@@ -152,15 +165,47 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
interface ContainerActionBarProps {
selectedIds: string[];
+ selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
}
-const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
+const ContainerActionBar = ({ selectedContainers, selectedIds }: ContainerActionBarProps) => {
return (
-
-
-
-
+
+
+
+
+
);
};
@@ -168,14 +213,30 @@ const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
interface ContainerActionBarButtonProps {
icon: TablerIcon;
color: MantineColor;
- action: "start" | "stop" | "restart" | "remove";
+ action: "start" | "stop" | "restart" | "remove" | "addToHomarr";
selectedIds: string[];
+ selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
}
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
const t = useScopedI18n("docker.action");
- const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
const utils = clientApi.useUtils();
+ const { openModal } = useModalAction(AddDockerAppToHomarr);
+
+ if (props.action === "addToHomarr") {
+ const handleClick = () => {
+ openModal({
+ selectedContainers: props.selectedContainers,
+ });
+ };
+ return (
+ } color={props.color} onClick={handleClick} variant="light" radius="md">
+ {t(`${props.action}.label`)}
+
+ );
+ }
+
+ const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
const handleClickAsync = async () => {
await mutateAsync(
diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts
index da4184e89..61b0e0ec6 100644
--- a/packages/api/src/router/app.ts
+++ b/packages/api/src/router/app.ts
@@ -8,6 +8,7 @@ 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";
+import { getIconForName } from "@homarr/icons";
export const appRouter = createTRPCRouter({
all: protectedProcedure
@@ -98,6 +99,24 @@ 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 ??
+ "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/homarr.svg",
+ href: app.href,
+ })),
+ );
+ }),
update: permissionRequiredProcedure
.requiresPermission("app-modify-all")
.input(convertIntersectionToZodObject(validation.app.edit))
diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts
index 1ef500ee0..b733a9b67 100644
--- a/packages/definitions/src/docs/homarr-docs-sitemap.ts
+++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts
@@ -9,6 +9,7 @@ export type HomarrDocumentationPath =
| "/blog/2023/12/22/updated-documentation"
| "/blog/2024/09/23/version-1.0"
| "/blog/2024/12/17/open-beta-1.0"
+ | "/blog/2024/12/31/migrate-secret-enryption-key"
| "/blog/archive"
| "/blog/authors"
| "/blog/authors/ajnart"
@@ -100,11 +101,13 @@ export type HomarrDocumentationPath =
| "/docs/tags/open-media-vault"
| "/docs/tags/overseerr"
| "/docs/tags/permissions"
+ | "/docs/tags/pgid"
| "/docs/tags/pi-hole"
| "/docs/tags/ping"
| "/docs/tags/programming"
| "/docs/tags/proxmox"
| "/docs/tags/proxy"
+ | "/docs/tags/puid"
| "/docs/tags/roles"
| "/docs/tags/rss"
| "/docs/tags/search"
@@ -135,6 +138,7 @@ export type HomarrDocumentationPath =
| "/docs/advanced/icons"
| "/docs/advanced/keyboard-shortcuts"
| "/docs/advanced/proxy"
+ | "/docs/advanced/running-as-different-user"
| "/docs/advanced/single-sign-on"
| "/docs/category/advanced"
| "/docs/category/community"
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..cf517f035
--- /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 { useForm, zodResolver } 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 AddDockerAppToHomarr = createModal(({ actions, innerProps }) => {
+ const t = useI18n();
+ const form = useForm({
+ validate: zodResolver(
+ 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..52c9a3a3c
--- /dev/null
+++ b/packages/modals-collection/src/docker/index.ts
@@ -0,0 +1 @@
+export { 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 9a87ad643..377838eae 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -2544,6 +2544,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,
};