Add "Add to homarr" feature and move code

This commit is contained in:
Thomas Camlong
2022-07-06 18:08:39 +02:00
parent be770d282a
commit 4b92c52ea8
8 changed files with 232 additions and 159 deletions

View File

@@ -92,6 +92,8 @@ function MatchPort(name: string, form: any) {
} }
} }
const DEFAULT_ICON = '/favicon.svg';
export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) { export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) {
const { setOpened } = props; const { setOpened } = props;
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
@@ -111,7 +113,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
type: props.type ?? 'Other', type: props.type ?? 'Other',
category: props.category ?? undefined, category: props.category ?? undefined,
name: props.name ?? '', name: props.name ?? '',
icon: props.icon ?? '/favicon.svg', icon: props.icon ?? DEFAULT_ICON,
url: props.url ?? '', url: props.url ?? '',
apiKey: props.apiKey ?? (undefined as unknown as string), apiKey: props.apiKey ?? (undefined as unknown as string),
username: props.username ?? (undefined as unknown as string), username: props.username ?? (undefined as unknown as string),
@@ -146,7 +148,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
const [debounced, cancel] = useDebouncedValue(form.values.name, 250); const [debounced, cancel] = useDebouncedValue(form.values.name, 250);
useEffect(() => { useEffect(() => {
if (form.values.name !== debounced || props.name || props.type) return; if (form.values.name !== debounced || form.values.icon !== DEFAULT_ICON) return;
MatchIcon(form.values.name, form); MatchIcon(form.values.name, form);
MatchService(form.values.name, form); MatchService(form.values.name, form);
MatchPort(form.values.name, form); MatchPort(form.values.name, form);
@@ -219,7 +221,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
<TextInput <TextInput
required required
label="Icon URL" label="Icon URL"
placeholder="/favicon.svg" placeholder={DEFAULT_ICON}
{...form.getInputProps('icon')} {...form.getInputProps('icon')}
/> />
<TextInput <TextInput

View File

@@ -2,20 +2,25 @@ import { Button, Group } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications'; import { showNotification, updateNotification } from '@mantine/notifications';
import { import {
IconCheck, IconCheck,
IconLicense,
IconPlayerPlay, IconPlayerPlay,
IconPlayerStop, IconPlayerStop,
IconPlus,
IconRefresh, IconRefresh,
IconRotateClockwise, IconRotateClockwise,
IconTrash,
IconX, IconX,
} from '@tabler/icons'; } from '@tabler/icons';
import axios from 'axios'; import axios from 'axios';
import Dockerode from 'dockerode'; import Dockerode from 'dockerode';
import addToHomarr from '../../tools/addToHomarr';
import { useConfig } from '../../tools/state';
function sendNotification(action: string, containerId: string, containerName: string) { function sendDockerCommand(action: string, containerId: string, containerName: string) {
showNotification({ showNotification({
id: containerId, id: containerId,
loading: true, loading: true,
title: `${action}ing container ${containerName}`, title: `${action}ing container ${containerName.substring(1)}`,
message: undefined, message: undefined,
autoClose: false, autoClose: false,
disallowClose: true, disallowClose: true,
@@ -51,6 +56,7 @@ export interface ContainerActionBarProps {
} }
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) { export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
const { config, setConfig } = useConfig();
return ( return (
<Group> <Group>
<Button <Button
@@ -58,7 +64,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
onClick={() => onClick={() =>
Promise.all( Promise.all(
selected.map((container) => selected.map((container) =>
sendNotification('restart', container.Id, container.Names[0]) sendDockerCommand('restart', container.Id, container.Names[0].substring(1))
) )
).then(() => reload()) ).then(() => reload())
} }
@@ -72,7 +78,17 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
leftIcon={<IconPlayerStop />} leftIcon={<IconPlayerStop />}
onClick={() => onClick={() =>
Promise.all( Promise.all(
selected.map((container) => sendNotification('stop', container.Id, container.Names[0])) selected.map((container) => {
if (container.State === 'stopped' || container.State === 'created' || container.State === 'exited') {
return showNotification({
id: container.Id,
title: `Failed to stop ${container.Names[0].substring(1)}`,
message: "You can't stop a stopped container",
autoClose: 1000,
});
}
return sendDockerCommand('stop', container.Id, container.Names[0].substring(1));
})
).then(() => reload()) ).then(() => reload())
} }
variant="light" variant="light"
@@ -85,7 +101,9 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
leftIcon={<IconPlayerPlay />} leftIcon={<IconPlayerPlay />}
onClick={() => onClick={() =>
Promise.all( Promise.all(
selected.map((container) => sendNotification('start', container.Id, container.Names[0])) selected.map((container) =>
sendDockerCommand('start', container.Id, container.Names[0].substring(1))
)
).then(() => reload()) ).then(() => reload())
} }
variant="light" variant="light"
@@ -94,9 +112,45 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
> >
Start Start
</Button> </Button>
<Button leftIcon={<IconRefresh />} onClick={() => reload()} variant="light"> <Button leftIcon={<IconRefresh />} onClick={() => reload()} variant="light" radius="md">
Refresh data Refresh data
</Button> </Button>
<Button
leftIcon={<IconPlus />}
color="indigo"
variant="light"
radius="md"
onClick={() =>
Promise.all(selected.map((container) => addToHomarr(container, config, setConfig))).then(
() => reload()
)
}
>
Add to Homarr
</Button>
<Button
leftIcon={<IconTrash />}
color="red"
variant="light"
radius="md"
onClick={() =>
Promise.all(
selected.map((container) => {
if (container.State === 'running') {
return showNotification({
id: container.Id,
title: `Failed to delete ${container.Names[0].substring(1)}`,
message: "You can't delete a running container",
autoClose: 1000,
});
}
return sendDockerCommand('remove', container.Id, container.Names[0].substring(1));
})
).then(() => reload())
}
>
Remove
</Button>
</Group> </Group>
); );
} }

View File

@@ -1,118 +1,41 @@
import { import { ActionIcon, Drawer, Group, LoadingOverlay, ScrollArea } from '@mantine/core';
ActionIcon,
Badge,
Checkbox,
createStyles,
Drawer,
Group,
List,
Menu,
ScrollArea,
Table,
Text,
} from '@mantine/core';
import { IconBrandDocker } from '@tabler/icons'; import { IconBrandDocker } from '@tabler/icons';
import axios from 'axios'; import axios from 'axios';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Docker from 'dockerode'; import Docker from 'dockerode';
import DockerMenu from './DockerMenu';
import ContainerState from './ContainerState';
import ContainerActionBar from './ContainerActionBar'; import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable';
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 DockerDrawer(props: any) { export default function DockerDrawer(props: any) {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]); const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]);
const { classes, cx } = useStyles();
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]); const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
const [visible, setVisible] = useState(false);
function reload() { function reload() {
setVisible(true);
setTimeout(() => {
axios.get('/api/docker/containers').then((res) => { axios.get('/api/docker/containers').then((res) => {
setContainers(res.data); setContainers(res.data);
setSelection([]);
setVisible(false);
}); });
}, 300);
} }
const toggleRow = (container: Docker.ContainerInfo) =>
setSelection((current) =>
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
);
const toggleAll = () =>
setSelection((current) =>
current.length === containers.length ? [] : containers.map((c) => c)
);
useEffect(() => { useEffect(() => {
reload(); reload();
}, []); }, []);
const rows = containers.map((element) => {
const selected = selection.includes(element);
return (
<tr key={element.Id} className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox
checked={selection.includes(element)}
onChange={() => toggleRow(element)}
transitionDuration={0}
/>
</td>
<td>{element.Names[0].replace('/', '')}</td>
<td>{element.Image}</td>
<td>
<Group>
{element.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
.slice(-3)
.map((port) => (
<Badge variant="outline">
{port.PrivatePort}:{port.PublicPort}
</Badge>
))}
{element.Ports.length > 3 && (
<Badge variant="filled">{element.Ports.length - 3} more</Badge>
)}
</Group>
</td>
<td>
<ContainerState state={element.State} />
</td>
</tr>
);
});
return ( return (
<> <>
<Drawer opened={opened} onClose={() => setOpened(false)} padding="xl" size="full"> <Drawer opened={opened} onClose={() => setOpened(false)} padding="xl" size="full">
<ScrollArea>
<ContainerActionBar selected={selection} reload={reload} /> <ContainerActionBar selected={selection} reload={reload} />
<Table captionSide="bottom" highlightOnHover sx={{ minWidth: 800 }} verticalSpacing="sm"> <div style={{ position: 'relative' }}>
<caption>your docker containers</caption> <LoadingOverlay visible={visible} />
<thead> <DockerTable containers={containers} selection={selection} setSelection={setSelection} />
<tr> </div>
<th style={{ width: 40 }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === containers.length}
indeterminate={selection.length > 0 && selection.length !== containers.length}
transitionDuration={0}
/>
</th>
<th>Name</th>
<th>Image</th>
<th>Ports</th>
<th>State</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
</Drawer> </Drawer>
<DockerMenu container={containers?.at(0)} />
<Group position="center"> <Group position="center">
<ActionIcon <ActionIcon
variant="default" variant="default"

View File

@@ -0,0 +1,90 @@
import { Table, Checkbox, Group, Badge, createStyles } from '@mantine/core';
import Dockerode from 'dockerode';
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: any;
containers: Dockerode.ContainerInfo[];
selection: Dockerode.ContainerInfo[];
}) {
const { classes, cx } = useStyles();
const toggleRow = (container: Dockerode.ContainerInfo) =>
setSelection((current: Dockerode.ContainerInfo[]) =>
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
);
const toggleAll = () =>
setSelection((current: any) =>
current.length === containers.length ? [] : containers.map((c) => c)
);
const rows = containers.map((element) => {
const selected = selection.includes(element);
return (
<tr key={element.Id} className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox
checked={selection.includes(element)}
onChange={() => toggleRow(element)}
transitionDuration={0}
/>
</td>
<td>{element.Names[0].replace('/', '')}</td>
<td>{element.Image}</td>
<td>
<Group>
{element.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
.slice(-3)
.map((port) => (
<Badge key={port.PrivatePort} variant="outline">
{port.PrivatePort}:{port.PublicPort}
</Badge>
))}
{element.Ports.length > 3 && (
<Badge variant="filled">{element.Ports.length - 3} more</Badge>
)}
</Group>
</td>
<td>
<ContainerState state={element.State} />
</td>
</tr>
);
});
return (
<Table captionSide="bottom" highlightOnHover sx={{ minWidth: 800 }} verticalSpacing="sm">
<caption>your docker containers</caption>
<thead>
<tr>
<th style={{ width: 40 }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === containers.length}
indeterminate={selection.length > 0 && selection.length !== containers.length}
transitionDuration={0}
/>
</th>
<th>Name</th>
<th>Image</th>
<th>Ports</th>
<th>State</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
);
}

View File

@@ -1,15 +0,0 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest, ev: NextFetchEvent) {
const ok = req.cookies.password === process.env.PASSWORD;
const url = req.nextUrl.clone();
if (
!ok &&
url.pathname !== '/login' &&
process.env.PASSWORD &&
url.pathname !== '/api/configs/tryPassword'
) {
url.pathname = '/login';
}
return NextResponse.rewrite(url);
}

View File

@@ -8,7 +8,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const { action } = req.query; const { action } = req.query;
// Get the action on the request (start, stop, restart) // Get the action on the request (start, stop, restart)
if (action !== 'start' && action !== 'stop' && action !== 'restart') { if (action !== 'start' && action !== 'stop' && action !== 'restart' && action !== 'remove') {
return res.status(400).json({ return res.status(400).json({
statusCode: 400, statusCode: 400,
message: 'Invalid action', message: 'Invalid action',
@@ -29,40 +29,26 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
}); });
} }
}); });
try {
switch (action) { switch (action) {
case 'remove':
await container.remove();
break;
case 'start': case 'start':
container.start((err, data) => { container.start();
if (err) {
res.status(500).json({
message: err,
});
}
});
break; break;
case 'stop': case 'stop':
container.stop((err, data) => { container.stop();
if (err) {
res.status(500).json({
message: err,
});
}
});
break; break;
case 'restart': case 'restart':
container.restart((err, data) => { container.restart();
if (err) { break;
}
} catch (err) {
res.status(500).json({ res.status(500).json({
message: err, message: err,
}); });
} }
});
break;
default:
res.status(400).json({
message: 'Invalid action',
});
}
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
}); });

View File

@@ -5,12 +5,8 @@ import Docker from 'dockerode';
const docker = new Docker(); const docker = new Docker();
async function Get(req: NextApiRequest, res: NextApiResponse) { async function Get(req: NextApiRequest, res: NextApiResponse) {
docker.listContainers({ all: true }, (err, containers) => { const containers = await docker.listContainers({ all: true });
if (err) {
res.status(500).json({ error: err });
}
return res.status(200).json(containers); return res.status(200).json(containers);
});
} }
export default async (req: NextApiRequest, res: NextApiResponse) => { export default async (req: NextApiRequest, res: NextApiResponse) => {

37
src/tools/addToHomarr.ts Normal file
View File

@@ -0,0 +1,37 @@
import Dockerode from 'dockerode';
import { Config } from './types';
async function MatchIcon(name: string) {
const res = await fetch(
`https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()}.png`
);
return res.ok ? res.url : '/favicon.svg';
}
function tryMatchType(imageName: string) {
// Search for a match with the Image name from the MATCH_TYPES array
console.log(`Trying to match type for: ${imageName}`);
return 'Other';
}
export default async function addToHomarr(
container: Dockerode.ContainerInfo,
config: Config,
setConfig: (newconfig: Config) => void
) {
setConfig({
...config,
services: [
...config.services,
{
name: container.Names[0].substring(1),
id: container.Id,
type: tryMatchType(container.Image),
url: `${container.Ports.at(0)?.IP}:${container.Ports.at(0)?.PublicPort}`,
icon: await MatchIcon(container.Names[0].substring(1)),
},
],
});
}