Readd possibility to add containers as apps to boards (#1276)

This commit is contained in:
Meier Lukas
2023-09-10 14:28:13 +02:00
committed by GitHub
parent f35f6debaf
commit d05c0023cd
13 changed files with 266 additions and 123 deletions

View File

@@ -4,5 +4,29 @@
"notConfigured": { "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." "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."
}
}
} }
} }

View File

@@ -6,11 +6,12 @@ import { motion } from 'framer-motion';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { useConfigContext } from '~/config/provider'; import { useConfigContext } from '~/config/provider';
import { useConfigStore } from '~/config/store'; import { useConfigStore } from '~/config/store';
import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions'; import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions';
import { generateDefaultApp } from '~/tools/shared/app';
import { AppType } from '~/types/app'; import { AppType } from '~/types/app';
import { CategoryEditModalInnerProps } from '../../../../Wrappers/Category/CategoryEditModal'; import { CategoryEditModalInnerProps } from '../../../../Wrappers/Category/CategoryEditModal';
import { useStyles } from '../Shared/styles'; import { useStyles } from '../Shared/styles';
@@ -66,7 +67,7 @@ export const AvailableElementTypes = ({
closeModal(modalId); closeModal(modalId);
showNotification({ showNotification({
title: t('category.created.title'), title: t('category.created.title'),
message: t('category.created.message', { name: category.name}), message: t('category.created.message', { name: category.name }),
color: 'teal', color: 'teal',
}); });
}); });
@@ -87,39 +88,8 @@ export const AvailableElementTypes = ({
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({ openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
modal: 'editApp', modal: 'editApp',
innerProps: { innerProps: {
app: { app: generateDefaultApp(getLowestWrapper()?.id ?? 'default'),
id: uuidv4(), // TODO: Add translation? t('app.defaultName')
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: [],
},
},
allowAppNamePropagation: true, allowAppNamePropagation: true,
}, },
size: 'xl', size: 'xl',
@@ -168,4 +138,4 @@ const ElementItem = ({ name, icon, onClick }: ElementItemProps) => {
</Stack> </Stack>
</UnstyledButton> </UnstyledButton>
); );
}; };

View File

@@ -4,6 +4,7 @@ import {
IconCheck, IconCheck,
IconPlayerPlay, IconPlayerPlay,
IconPlayerStop, IconPlayerStop,
IconPlus,
IconRefresh, IconRefresh,
IconRotateClockwise, IconRotateClockwise,
IconTrash, IconTrash,
@@ -12,6 +13,8 @@ import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { RouterInputs, api } from '~/utils/api'; import { RouterInputs, api } from '~/utils/api';
import { openDockerSelectBoardModal } from './docker-select-board.modal';
export interface ContainerActionBarProps { export interface ContainerActionBarProps {
selected: Dockerode.ContainerInfo[]; selected: Dockerode.ContainerInfo[];
reload: () => void; reload: () => void;
@@ -86,6 +89,16 @@ export default function ContainerActionBar({
> >
{t('actionBar.remove.title')} {t('actionBar.remove.title')}
</Button> </Button>
<Button
leftIcon={<IconPlus />}
color="indigo"
variant="light"
radius="md"
disabled={selected.length !== 1}
onClick={() => openDockerSelectBoardModal({ containers: selected })}
>
{t('actionBar.addToHomarr.title')}
</Button>
</Group> </Group>
); );
} }

View File

@@ -8,13 +8,13 @@ import {
TextInput, TextInput,
createStyles, createStyles,
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue, useElementSize } from '@mantine/hooks'; import { useElementSize } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react'; import { IconSearch } from '@tabler/icons-react';
import Dockerode, { ContainerInfo } from 'dockerode'; import Dockerode, { ContainerInfo } from 'dockerode';
import { useTranslation } from 'next-i18next'; 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'; import ContainerState from './ContainerState';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
@@ -26,7 +26,7 @@ const useStyles = createStyles((theme) => ({
}, },
})); }));
export default function DockerTable({ export default function ContainerTable({
containers, containers,
selection, selection,
setSelection, setSelection,
@@ -37,7 +37,6 @@ export default function DockerTable({
}) { }) {
const { t } = useTranslation('modules/docker'); const { t } = useTranslation('modules/docker');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { classes, cx } = useStyles();
const { ref, width } = useElementSize(); const { ref, width } = useElementSize();
const filteredContainers = useMemo( const filteredContainers = useMemo(

View File

@@ -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<typeof dockerSelectBoardSchema>;
export const DockerSelectBoardModal = ({ id, innerProps }: ContextModalProps<InnerProps>) => {
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: <IconCheck />,
color: 'green',
});
modals.close(id);
},
onError: () => {
showNotification({
title: t('notifications.selectBoard.error.title'),
message: t('notifications.selectBoard.error.message'),
icon: <IconX />,
color: 'red',
});
},
}
);
};
const form = useForm<FormType>({
initialValues: {
board: '',
},
validate: i18nZodResolver(dockerSelectBoardSchema),
});
const { data: boards } = api.boards.all.useQuery();
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Text>{t('modals.selectBoard.text')}</Text>
<Select
label={t('modals.selectBoard.form.board.label')}
withAsterisk
withinPortal
data={
boards?.map((board) => ({
value: board.name,
label: board.name,
})) ?? []
}
{...form.getInputProps('board')}
/>
<Group grow>
<Button
onClick={() => {
modals.close(id);
}}
variant="light"
color="gray"
type="button"
>
{t('common:cancel')}
</Button>
<Button
type="submit"
onClick={async () => {}}
disabled={isLoading}
variant="light"
color="green"
>
{t('modals.selectBoard.form.submit')}
</Button>
</Group>
</Stack>
</form>
);
};
export const openDockerSelectBoardModal = (innerProps: InnerProps) => {
modals.openContextModal({
modal: 'dockerSelectBoardModal',
title: (
<Title order={4}>
<Trans i18nKey="tools/docker:modals.selectBoard.title" />
</Title>
),
innerProps,
});
};

View File

@@ -19,7 +19,6 @@ import { useNamedWrapperColumnCount } from '~/components/Dashboard/Wrappers/grid
import { BoardHeadOverride } from '~/components/layout/Meta/BoardHeadOverride'; import { BoardHeadOverride } from '~/components/layout/Meta/BoardHeadOverride';
import { HeaderActionButton } from '~/components/layout/header/ActionButton'; import { HeaderActionButton } from '~/components/layout/header/ActionButton';
import { useConfigContext } from '~/config/provider'; import { useConfigContext } from '~/config/provider';
import { env } from '~/env';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
import { MainLayout } from './MainLayout'; import { MainLayout } from './MainLayout';
@@ -65,7 +64,7 @@ const DockerButton = () => {
return ( return (
<Tooltip label={t('actionIcon.tooltip')}> <Tooltip label={t('actionIcon.tooltip')}>
<HeaderActionButton component={Link} href="/docker"> <HeaderActionButton component={Link} href="/manage/tools/docker">
<IconBrandDocker size={20} stroke={1.5} /> <IconBrandDocker size={20} stroke={1.5} />
</HeaderActionButton> </HeaderActionButton>
</Tooltip> </Tooltip>

View File

@@ -8,6 +8,7 @@ import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/Cate
import { CreateBoardModal } from './components/Manage/Board/create-board.modal'; import { CreateBoardModal } from './components/Manage/Board/create-board.modal';
import { DeleteBoardModal } from './components/Manage/Board/delete-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 { CopyInviteModal } from './components/Manage/User/Invite/copy-invite.modal';
import { CreateInviteModal } from './components/Manage/User/Invite/create-invite.modal'; import { CreateInviteModal } from './components/Manage/User/Invite/create-invite.modal';
import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal'; import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal';
@@ -29,6 +30,7 @@ export const modals = {
copyInviteModal: CopyInviteModal, copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal, deleteBoardModal: DeleteBoardModal,
changeUserRoleModal: ChangeUserRoleModal, changeUserRoleModal: ChangeUserRoleModal,
dockerSelectBoardModal: DockerSelectBoardModal,
}; };
declare module '@mantine/modals' { declare module '@mantine/modals' {

View File

@@ -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<Docker.ContainerInfo[]>([]);
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 (
<>
<Drawer
opened={opened}
trapFocus={false}
onClose={() => setOpened(false)}
padding="xl"
position="right"
size="100%"
title={<ContainerActionBar isLoading={isLoading} selected={selection} reload={reload} />}
transitionProps={{
transition: 'pop',
}}
styles={{
content: {
display: 'flex',
flexDirection: 'column',
},
body: {
minHeight: 0,
},
}}
>
<DockerTable containers={data ?? []} selection={selection} setSelection={setSelection} />
</Drawer>
<Tooltip label={t('actionIcon.tooltip')}>
<ActionIcon
variant="default"
className={classes.card}
radius="md"
size="xl"
color="blue"
onClick={() => setOpened(true)}
>
<IconBrandDocker />
</ActionIcon>
</Tooltip>
</>
);
}

View File

@@ -5,9 +5,9 @@ import { ContainerInfo } from 'dockerode';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useState } from 'react'; 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 { 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 { dockerRouter } from '~/server/api/routers/docker/router';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { prisma } from '~/server/db'; import { prisma } from '~/server/db';
@@ -49,7 +49,7 @@ export default function DockerPage({
<ManageLayout> <ManageLayout>
<Stack> <Stack>
<ContainerActionBar selected={selection} reload={reload} isLoading={isRefetching} /> <ContainerActionBar selected={selection} reload={reload} isLoading={isRefetching} />
<DockerTable containers={data ?? []} selection={selection} setSelection={setSelection} /> <ContainerTable containers={data ?? []} selection={selection} setSelection={setSelection} />
</Stack> </Stack>
</ManageLayout> </ManageLayout>
); );

View File

@@ -1,7 +1,13 @@
import { TRPCError } from '@trpc/server';
import fs from 'fs'; 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 { 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({ export const boardRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { 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');
}),
}); });

View File

@@ -13,7 +13,7 @@ import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
import { getConfig } from '~/tools/config/getConfig'; import { getConfig } from '~/tools/config/getConfig';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc'; 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({ export const configRouter = createTRPCRouter({
delete: adminProcedure delete: adminProcedure

36
src/tools/shared/app.ts Normal file
View File

@@ -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;