diff --git a/public/locales/en/tools/docker.json b/public/locales/en/tools/docker.json
index ff8250b72..95c67f0d8 100644
--- a/public/locales/en/tools/docker.json
+++ b/public/locales/en/tools/docker.json
@@ -4,5 +4,29 @@
"notConfigured": {
"text": "Your Homarr instance does not have Docker configured or it has falied to fetch containers. Please check the documentation on how to set up the integration."
}
+ },
+ "modals": {
+ "selectBoard": {
+ "title": "Choose a board",
+ "text": "Choose the board where you want to add the apps for the selected Docker containers.",
+ "form": {
+ "board": {
+ "label": "Board"
+ },
+ "submit": "Add apps"
+ }
+ }
+ },
+ "notifications": {
+ "selectBoard": {
+ "success": {
+ "title": "Added apps to board",
+ "message": "The apps for the selected Docker containers have been added to the board."
+ },
+ "error": {
+ "title": "Failed to add apps to board",
+ "message": "The apps for the selected Docker containers could not be added to the board."
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx
index d556602b3..c1b2045db 100644
--- a/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx
+++ b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx
@@ -6,11 +6,12 @@ import { motion } from 'framer-motion';
import { useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
-
import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store';
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
+import { generateDefaultApp } from '~/tools/shared/app';
import { AppType } from '~/types/app';
+
import { CategoryEditModalInnerProps } from '../../../../Wrappers/Category/CategoryEditModal';
import { useStyles } from '../Shared/styles';
@@ -66,7 +67,7 @@ export const AvailableElementTypes = ({
closeModal(modalId);
showNotification({
title: t('category.created.title'),
- message: t('category.created.message', { name: category.name}),
+ message: t('category.created.message', { name: category.name }),
color: 'teal',
});
});
@@ -87,39 +88,8 @@ export const AvailableElementTypes = ({
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
modal: 'editApp',
innerProps: {
- app: {
- id: uuidv4(),
- name: t('app.defaultName'),
- url: 'https://homarr.dev',
- appearance: {
- iconUrl: '/imgs/logo/logo.png',
- appNameStatus: 'normal',
- appNameFontSize: 16,
- positionAppName: 'column',
- lineClampAppName: 1,
- },
- network: {
- enabledStatusChecker: true,
- statusCodes: ['200', '301', '302', '304', '307', '308'],
- okStatus: [200, 301, 302, 304, 307, 308],
- },
- behaviour: {
- isOpeningNewTab: true,
- externalUrl: 'https://homarr.dev',
- },
-
- area: {
- type: 'wrapper',
- properties: {
- id: getLowestWrapper()?.id ?? 'default',
- },
- },
- shape: {},
- integration: {
- type: null,
- properties: [],
- },
- },
+ app: generateDefaultApp(getLowestWrapper()?.id ?? 'default'),
+ // TODO: Add translation? t('app.defaultName')
allowAppNamePropagation: true,
},
size: 'xl',
@@ -168,4 +138,4 @@ const ElementItem = ({ name, icon, onClick }: ElementItemProps) => {
);
-};
\ No newline at end of file
+};
diff --git a/src/modules/Docker/ContainerActionBar.tsx b/src/components/Manage/Tools/Docker/ContainerActionBar.tsx
similarity index 90%
rename from src/modules/Docker/ContainerActionBar.tsx
rename to src/components/Manage/Tools/Docker/ContainerActionBar.tsx
index 8a3cdc381..cf18de50c 100644
--- a/src/modules/Docker/ContainerActionBar.tsx
+++ b/src/components/Manage/Tools/Docker/ContainerActionBar.tsx
@@ -4,6 +4,7 @@ import {
IconCheck,
IconPlayerPlay,
IconPlayerStop,
+ IconPlus,
IconRefresh,
IconRotateClockwise,
IconTrash,
@@ -12,6 +13,8 @@ import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
import { RouterInputs, api } from '~/utils/api';
+import { openDockerSelectBoardModal } from './docker-select-board.modal';
+
export interface ContainerActionBarProps {
selected: Dockerode.ContainerInfo[];
reload: () => void;
@@ -86,6 +89,16 @@ export default function ContainerActionBar({
>
{t('actionBar.remove.title')}
+ }
+ color="indigo"
+ variant="light"
+ radius="md"
+ disabled={selected.length !== 1}
+ onClick={() => openDockerSelectBoardModal({ containers: selected })}
+ >
+ {t('actionBar.addToHomarr.title')}
+
);
}
diff --git a/src/modules/Docker/ContainerState.tsx b/src/components/Manage/Tools/Docker/ContainerState.tsx
similarity index 100%
rename from src/modules/Docker/ContainerState.tsx
rename to src/components/Manage/Tools/Docker/ContainerState.tsx
diff --git a/src/modules/Docker/DockerTable.tsx b/src/components/Manage/Tools/Docker/ContainerTable.tsx
similarity index 94%
rename from src/modules/Docker/DockerTable.tsx
rename to src/components/Manage/Tools/Docker/ContainerTable.tsx
index f51e1e00c..bd0f76c84 100644
--- a/src/modules/Docker/DockerTable.tsx
+++ b/src/components/Manage/Tools/Docker/ContainerTable.tsx
@@ -8,13 +8,13 @@ import {
TextInput,
createStyles,
} from '@mantine/core';
-import { useDebouncedValue, useElementSize } from '@mantine/hooks';
+import { useElementSize } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react';
import Dockerode, { ContainerInfo } from 'dockerode';
import { useTranslation } from 'next-i18next';
-import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
+import { Dispatch, SetStateAction, useMemo, useState } from 'react';
-import { MIN_WIDTH_MOBILE } from '~/constants/constants';
+import { MIN_WIDTH_MOBILE } from '../../../../constants/constants';
import ContainerState from './ContainerState';
const useStyles = createStyles((theme) => ({
@@ -26,7 +26,7 @@ const useStyles = createStyles((theme) => ({
},
}));
-export default function DockerTable({
+export default function ContainerTable({
containers,
selection,
setSelection,
@@ -37,7 +37,6 @@ export default function DockerTable({
}) {
const { t } = useTranslation('modules/docker');
const [search, setSearch] = useState('');
- const { classes, cx } = useStyles();
const { ref, width } = useElementSize();
const filteredContainers = useMemo(
diff --git a/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx b/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx
new file mode 100644
index 000000000..2567a7551
--- /dev/null
+++ b/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx
@@ -0,0 +1,120 @@
+import { Button, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core';
+import { useForm } from '@mantine/form';
+import { ContextModalProps, modals } from '@mantine/modals';
+import { showNotification } from '@mantine/notifications';
+import { IconCheck, IconX } from '@tabler/icons-react';
+import { ContainerInfo } from 'dockerode';
+import { Trans, useTranslation } from 'next-i18next';
+import { z } from 'zod';
+import { api } from '~/utils/api';
+import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
+
+const dockerSelectBoardSchema = z.object({
+ board: z.string().nonempty(),
+});
+
+type InnerProps = {
+ containers: ContainerInfo[];
+};
+type FormType = z.infer;
+
+export const DockerSelectBoardModal = ({ id, innerProps }: ContextModalProps) => {
+ const { t } = useTranslation('tools/docker');
+ const { mutateAsync, isLoading } = api.boards.addAppsForContainers.useMutation();
+ const { i18nZodResolver } = useI18nZodResolver();
+ const handleSubmit = async (values: FormType) => {
+ await mutateAsync(
+ {
+ apps: innerProps.containers.map((container) => ({
+ name: container.Names.at(0) ?? 'App',
+ port: container.Ports.at(0)?.PublicPort,
+ })),
+ boardName: values.board,
+ },
+ {
+ onSuccess: () => {
+ showNotification({
+ title: t('notifications.selectBoard.success.title'),
+ message: t('notifications.selectBoard.success.message'),
+ icon: ,
+ color: 'green',
+ });
+
+ modals.close(id);
+ },
+ onError: () => {
+ showNotification({
+ title: t('notifications.selectBoard.error.title'),
+ message: t('notifications.selectBoard.error.message'),
+ icon: ,
+ color: 'red',
+ });
+ },
+ }
+ );
+ };
+
+ const form = useForm({
+ initialValues: {
+ board: '',
+ },
+ validate: i18nZodResolver(dockerSelectBoardSchema),
+ });
+
+ const { data: boards } = api.boards.all.useQuery();
+
+ return (
+
+ );
+};
+
+export const openDockerSelectBoardModal = (innerProps: InnerProps) => {
+ modals.openContextModal({
+ modal: 'dockerSelectBoardModal',
+ title: (
+
+
+
+ ),
+ innerProps,
+ });
+};
diff --git a/src/components/layout/Templates/BoardLayout.tsx b/src/components/layout/Templates/BoardLayout.tsx
index 92e4d3c71..70f46b423 100644
--- a/src/components/layout/Templates/BoardLayout.tsx
+++ b/src/components/layout/Templates/BoardLayout.tsx
@@ -19,7 +19,6 @@ import { useNamedWrapperColumnCount } from '~/components/Dashboard/Wrappers/grid
import { BoardHeadOverride } from '~/components/layout/Meta/BoardHeadOverride';
import { HeaderActionButton } from '~/components/layout/header/ActionButton';
import { useConfigContext } from '~/config/provider';
-import { env } from '~/env';
import { api } from '~/utils/api';
import { MainLayout } from './MainLayout';
@@ -65,7 +64,7 @@ const DockerButton = () => {
return (
-
+
diff --git a/src/modals.ts b/src/modals.ts
index 90865cb68..4faea9bd3 100644
--- a/src/modals.ts
+++ b/src/modals.ts
@@ -8,6 +8,7 @@ import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/Cate
import { CreateBoardModal } from './components/Manage/Board/create-board.modal';
import { DeleteBoardModal } from './components/Manage/Board/delete-board.modal';
+import { DockerSelectBoardModal } from './components/Manage/Tools/Docker/docker-select-board.modal';
import { CopyInviteModal } from './components/Manage/User/Invite/copy-invite.modal';
import { CreateInviteModal } from './components/Manage/User/Invite/create-invite.modal';
import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal';
@@ -29,6 +30,7 @@ export const modals = {
copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal,
changeUserRoleModal: ChangeUserRoleModal,
+ dockerSelectBoardModal: DockerSelectBoardModal,
};
declare module '@mantine/modals' {
diff --git a/src/modules/Docker/DockerModule.tsx b/src/modules/Docker/DockerModule.tsx
deleted file mode 100644
index 513e364d9..000000000
--- a/src/modules/Docker/DockerModule.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { ActionIcon, Drawer, Tooltip } from '@mantine/core';
-import { useHotkeys } from '@mantine/hooks';
-import { IconBrandDocker } from '@tabler/icons-react';
-import Docker from 'dockerode';
-import { useTranslation } from 'next-i18next';
-import { useState } from 'react';
-import { api } from '~/utils/api';
-
-import { useCardStyles } from '~/components/layout/Common/useCardStyles';
-import { useConfigContext } from '~/config/provider';
-import ContainerActionBar from './ContainerActionBar';
-import DockerTable from './DockerTable';
-
-export default function DockerMenuButton(props: any) {
- const [opened, setOpened] = useState(false);
- const [selection, setSelection] = useState([]);
- const { config } = useConfigContext();
- const { classes } = useCardStyles(true);
-
- const dockerEnabled = config?.settings.customization.layout.enabledDocker || false;
-
- const { data, refetch, isLoading } = api.docker.containers.useQuery(undefined, {
- enabled: dockerEnabled,
- });
- useHotkeys([['mod+B', () => setOpened(!opened)]]);
-
- const { t } = useTranslation('modules/docker');
-
- const reload = () => {
- refetch();
- setSelection([]);
- };
-
- if (!dockerEnabled) return null;
-
- return (
- <>
- setOpened(false)}
- padding="xl"
- position="right"
- size="100%"
- title={}
- transitionProps={{
- transition: 'pop',
- }}
- styles={{
- content: {
- display: 'flex',
- flexDirection: 'column',
- },
- body: {
- minHeight: 0,
- },
- }}
- >
-
-
-
- setOpened(true)}
- >
-
-
-
- >
- );
-}
diff --git a/src/pages/manage/tools/docker.tsx b/src/pages/manage/tools/docker.tsx
index ea3b10b79..f8b0be2cb 100644
--- a/src/pages/manage/tools/docker.tsx
+++ b/src/pages/manage/tools/docker.tsx
@@ -5,9 +5,9 @@ import { ContainerInfo } from 'dockerode';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
+import ContainerActionBar from '~/components/Manage/Tools/Docker/ContainerActionBar';
+import ContainerTable from '~/components/Manage/Tools/Docker/ContainerTable';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
-import ContainerActionBar from '~/modules/Docker/ContainerActionBar';
-import DockerTable from '~/modules/Docker/DockerTable';
import { dockerRouter } from '~/server/api/routers/docker/router';
import { getServerAuthSession } from '~/server/auth';
import { prisma } from '~/server/db';
@@ -49,7 +49,7 @@ export default function DockerPage({
-
+
);
diff --git a/src/server/api/routers/board.ts b/src/server/api/routers/board.ts
index 2e6dc2bf2..ac16e9b09 100644
--- a/src/server/api/routers/board.ts
+++ b/src/server/api/routers/board.ts
@@ -1,7 +1,13 @@
+import { TRPCError } from '@trpc/server';
import fs from 'fs';
+import { z } from 'zod';
+import { configExists } from '~/tools/config/configExists';
+import { getConfig } from '~/tools/config/getConfig';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
+import { generateDefaultApp } from '~/tools/shared/app';
-import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
+import { adminProcedure, createTRPCRouter, protectedProcedure } from '../trpc';
+import { configNameSchema } from './config';
export const boardRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
@@ -30,4 +36,53 @@ export const boardRouter = createTRPCRouter({
})
);
}),
+ addAppsForContainers: adminProcedure
+ .input(
+ z.object({
+ boardName: configNameSchema,
+ apps: z.array(
+ z.object({
+ name: z.string(),
+ port: z.number().optional(),
+ })
+ ),
+ })
+ )
+ .mutation(async ({ input }) => {
+ if (!(await configExists(input.boardName))) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Board not found',
+ });
+ }
+ const config = await getConfig(input.boardName);
+
+ const lowestWrapper = config?.wrappers.sort((a, b) => a.position - b.position)[0];
+
+ const newConfig = {
+ ...config,
+ apps: [
+ ...config.apps,
+ ...input.apps.map((container) => {
+ const defaultApp = generateDefaultApp(lowestWrapper.id);
+ const address = container.port
+ ? `http://localhost:${container.port}`
+ : 'http://localhost';
+
+ return {
+ ...defaultApp,
+ name: container.name,
+ url: address,
+ behaviour: {
+ ...defaultApp.behaviour,
+ externalUrl: address,
+ },
+ };
+ }),
+ ],
+ };
+
+ const targetPath = `data/configs/${input.boardName}.json`;
+ fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
+ }),
});
diff --git a/src/server/api/routers/config.ts b/src/server/api/routers/config.ts
index f40add72b..a7e0e063e 100644
--- a/src/server/api/routers/config.ts
+++ b/src/server/api/routers/config.ts
@@ -13,7 +13,7 @@ import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
import { getConfig } from '~/tools/config/getConfig';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
-const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);
+export const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);
export const configRouter = createTRPCRouter({
delete: adminProcedure
diff --git a/src/tools/shared/app.ts b/src/tools/shared/app.ts
new file mode 100644
index 000000000..c4d869913
--- /dev/null
+++ b/src/tools/shared/app.ts
@@ -0,0 +1,36 @@
+import { v4 as uuidv4 } from 'uuid';
+import { AppType } from '~/types/app';
+
+export const generateDefaultApp = (wrapperId: string): AppType =>
+ ({
+ id: uuidv4(),
+ name: 'Your app',
+ url: 'https://homarr.dev',
+ appearance: {
+ iconUrl: '/imgs/logo/logo.png',
+ appNameStatus: 'normal',
+ positionAppName: 'column',
+ lineClampAppName: 1,
+ appNameFontSize: 16
+ },
+ network: {
+ enabledStatusChecker: true,
+ statusCodes: ['200', '301', '302', '304', '307', '308'],
+ okStatus: [200, 301, 302, 304, 307, 308],
+ },
+ behaviour: {
+ isOpeningNewTab: true,
+ externalUrl: 'https://homarr.dev',
+ },
+ area: {
+ type: 'wrapper',
+ properties: {
+ id: wrapperId,
+ },
+ },
+ shape: {},
+ integration: {
+ type: null,
+ properties: [],
+ },
+ }) satisfies AppType;