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

@@ -1,140 +0,0 @@
import { Button, Group } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconCheck,
IconPlayerPlay,
IconPlayerStop,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from '@tabler/icons-react';
import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
import { RouterInputs, api } from '~/utils/api';
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>
</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

@@ -1,53 +0,0 @@
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

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

@@ -1,172 +0,0 @@
import {
Badge,
Checkbox,
Group,
ScrollArea,
Table,
Text,
TextInput,
createStyles,
} from '@mantine/core';
import { useDebouncedValue, 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 { 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 DockerTable({
containers,
selection,
setSelection,
}: {
setSelection: Dispatch<SetStateAction<ContainerInfo[]>>;
containers: ContainerInfo[];
selection: ContainerInfo[];
}) {
const { t } = useTranslation('modules/docker');
const [search, setSearch] = useState('');
const { classes, cx } = useStyles();
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))
);
}