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

@@ -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) => {
</Stack>
</UnstyledButton>
);
};
};

View File

@@ -0,0 +1,153 @@
import { Button, Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconCheck,
IconPlayerPlay,
IconPlayerStop,
IconPlus,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from '@tabler/icons-react';
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;
isLoading: boolean;
}
export default function ContainerActionBar({
selected,
reload,
isLoading,
}: ContainerActionBarProps) {
const { t } = useTranslation('modules/docker');
const sendDockerCommand = useDockerActionMutation();
return (
<Group spacing="xs">
<Button
leftIcon={<IconRefresh />}
onClick={reload}
variant="light"
color="violet"
loading={isLoading}
radius="md"
>
{t('actionBar.refreshData.title')}
</Button>
<Button
leftIcon={<IconRotateClockwise />}
onClick={async () =>
await Promise.all(selected.map((container) => sendDockerCommand(container, 'restart')))
}
variant="light"
color="orange"
radius="md"
disabled={selected.length === 0}
>
{t('actionBar.restart.title')}
</Button>
<Button
leftIcon={<IconPlayerStop />}
onClick={async () =>
await Promise.all(selected.map((container) => sendDockerCommand(container, 'stop')))
}
variant="light"
color="red"
radius="md"
disabled={selected.length === 0}
>
{t('actionBar.stop.title')}
</Button>
<Button
leftIcon={<IconPlayerPlay />}
onClick={async () =>
await Promise.all(selected.map((container) => sendDockerCommand(container, 'start')))
}
variant="light"
color="green"
radius="md"
disabled={selected.length === 0}
>
{t('actionBar.start.title')}
</Button>
<Button
leftIcon={<IconTrash />}
color="red"
variant="light"
radius="md"
onClick={async () =>
await Promise.all(selected.map((container) => sendDockerCommand(container, 'remove')))
}
disabled={selected.length === 0}
>
{t('actionBar.remove.title')}
</Button>
<Button
leftIcon={<IconPlus />}
color="indigo"
variant="light"
radius="md"
disabled={selected.length !== 1}
onClick={() => openDockerSelectBoardModal({ containers: selected })}
>
{t('actionBar.addToHomarr.title')}
</Button>
</Group>
);
}
const useDockerActionMutation = () => {
const { t } = useTranslation('modules/docker');
const utils = api.useContext();
const mutation = api.docker.action.useMutation();
return async (
container: Dockerode.ContainerInfo,
action: RouterInputs['docker']['action']['action']
) => {
const containerName = container.Names[0].substring(1);
notifications.show({
id: container.Id,
loading: true,
title: `${t(`actions.${action}.start`)} ${containerName}`,
message: undefined,
autoClose: false,
withCloseButton: false,
});
await mutation.mutateAsync(
{ action, id: container.Id },
{
onSuccess: () => {
notifications.update({
id: container.Id,
title: containerName,
message: `${t(`actions.${action}.end`)} ${containerName}`,
icon: <IconCheck />,
autoClose: 2000,
});
},
onError: (err) => {
notifications.update({
id: container.Id,
color: 'red',
title: t('errors.unknownError.title'),
message: err.message,
autoClose: 2000,
});
},
onSettled: () => {
utils.docker.containers.invalidate();
},
}
);
};
};

View File

@@ -0,0 +1,53 @@
import { Badge, BadgeProps, MantineSize } from '@mantine/core';
import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
export interface ContainerStateProps {
state: Dockerode.ContainerInfo['State'];
}
export default function ContainerState(props: ContainerStateProps) {
const { state } = props;
const { t } = useTranslation('modules/docker');
const options: {
size: MantineSize;
radius: MantineSize;
variant: BadgeProps['variant'];
} = {
size: 'md',
radius: 'md',
variant: 'outline',
};
switch (state) {
case 'running': {
return (
<Badge color="green" {...options}>
{t('table.states.running')}
</Badge>
);
}
case 'created': {
return (
<Badge color="cyan" {...options}>
{t('table.states.created')}
</Badge>
);
}
case 'exited': {
return (
<Badge color="red" {...options}>
{t('table.states.stopped')}
</Badge>
);
}
default: {
return (
<Badge color="purple" {...options}>
{t('table.states.unknown')}
</Badge>
);
}
}
}

View File

@@ -0,0 +1,171 @@
import {
Badge,
Checkbox,
Group,
ScrollArea,
Table,
Text,
TextInput,
createStyles,
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons-react';
import Dockerode, { ContainerInfo } from 'dockerode';
import { useTranslation } from 'next-i18next';
import { Dispatch, SetStateAction, useMemo, useState } from 'react';
import { MIN_WIDTH_MOBILE } from '../../../../constants/constants';
import ContainerState from './ContainerState';
const useStyles = createStyles((theme) => ({
rowSelected: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
: theme.colors[theme.primaryColor][0],
},
}));
export default function ContainerTable({
containers,
selection,
setSelection,
}: {
setSelection: Dispatch<SetStateAction<ContainerInfo[]>>;
containers: ContainerInfo[];
selection: ContainerInfo[];
}) {
const { t } = useTranslation('modules/docker');
const [search, setSearch] = useState('');
const { ref, width } = useElementSize();
const filteredContainers = useMemo(
() => filterContainers(containers, search),
[containers, search]
);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearch(event.currentTarget.value);
};
const toggleRow = (container: ContainerInfo) =>
setSelection((selected: ContainerInfo[]) =>
selected.includes(container)
? selected.filter((c) => c !== container)
: [...selected, container]
);
const toggleAll = () =>
setSelection((selected: ContainerInfo[]) =>
selected.length === filteredContainers.length ? [] : filteredContainers.map((c) => c)
);
return (
<ScrollArea style={{ height: '100%' }} offsetScrollbars>
<TextInput
placeholder={t('search.placeholder') ?? undefined}
mr="md"
icon={<IconSearch size={14} />}
value={search}
autoFocus
onChange={handleSearchChange}
/>
<Table ref={ref} captionSide="bottom" highlightOnHover verticalSpacing="sm">
<thead>
<tr>
<th style={{ width: 40 }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === filteredContainers.length && selection.length > 0}
indeterminate={
selection.length > 0 && selection.length !== filteredContainers.length
}
transitionDuration={0}
disabled={filteredContainers.length === 0}
/>
</th>
<th>{t('table.header.name')}</th>
{width > MIN_WIDTH_MOBILE ? <th>{t('table.header.image')}</th> : null}
{width > MIN_WIDTH_MOBILE ? <th>{t('table.header.ports')}</th> : null}
<th>{t('table.header.state')}</th>
</tr>
</thead>
<tbody>
{filteredContainers.map((container) => {
const selected = selection.includes(container);
return (
<Row
key={container.Id}
container={container}
selected={selected}
toggleRow={toggleRow}
width={width}
/>
);
})}
</tbody>
</Table>
</ScrollArea>
);
}
type RowProps = {
container: ContainerInfo;
selected: boolean;
toggleRow: (container: ContainerInfo) => void;
width: number;
};
const Row = ({ container, selected, toggleRow, width }: RowProps) => {
const { t } = useTranslation('modules/docker');
const { classes, cx } = useStyles();
return (
<tr className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox checked={selected} onChange={() => toggleRow(container)} transitionDuration={0} />
</td>
<td>
<Text size="lg" weight={600}>
{container.Names[0].replace('/', '')}
</Text>
</td>
{width > MIN_WIDTH_MOBILE && (
<td>
<Text size="lg">{container.Image}</Text>
</td>
)}
{width > MIN_WIDTH_MOBILE && (
<td>
<Group>
{container.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
// Remove duplicates with filter function
.filter(
(port, index, self) =>
index === self.findIndex((t) => t.PrivatePort === port.PrivatePort)
)
.slice(-3)
.map((port) => (
<Badge key={port.PrivatePort} variant="outline">
{port.PrivatePort}:{port.PublicPort}
</Badge>
))}
{container.Ports.length > 3 && (
<Badge variant="filled">
{t('table.body.portCollapse', { ports: container.Ports.length - 3 })}
</Badge>
)}
</Group>
</td>
)}
<td>
<ContainerState state={container.State} />
</td>
</tr>
);
};
function filterContainers(data: Dockerode.ContainerInfo[], search: string) {
const query = search.toLowerCase().trim();
return data.filter((item) =>
item.Names.some((name) => name.toLowerCase().includes(query) || item.Image.includes(query))
);
}

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 { 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 (
<Tooltip label={t('actionIcon.tooltip')}>
<HeaderActionButton component={Link} href="/docker">
<HeaderActionButton component={Link} href="/manage/tools/docker">
<IconBrandDocker size={20} stroke={1.5} />
</HeaderActionButton>
</Tooltip>