🚧 WIP on Docker import group

This commit is contained in:
ajnart
2023-11-17 15:34:14 +01:00
parent 228c51299b
commit 669d311b0c
12 changed files with 275 additions and 104 deletions

View File

@@ -21,5 +21,6 @@
"title": "Category created",
"message": "The category \"{{name}}\" has been created"
}
}
},
"importFromDocker": "Import from docker"
}

View File

@@ -0,0 +1,167 @@
import { Button, Card, Center, Checkbox, Grid, Group, Image, Stack, Text } from '@mantine/core';
import { closeAllModals, closeModal } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import Dockerode from 'dockerode';
import { motion } from 'framer-motion';
import { useState } from 'react';
import ContainerState from '~/components/Manage/Tools/Docker/ContainerState';
import { useConfigContext } from '~/config/provider';
import { NormalizedIconRepositoryResult } from '~/tools/server/images/abstract-icons-repository';
import { api } from '~/utils/api';
import { ConditionalWrapper } from '~/utils/security';
import { WidgetLoading } from '~/widgets/loading';
import { SelectorBackArrow } from './Shared/SelectorBackArrow';
function DockerDispaly({
container,
selected,
setSelected,
iconsData,
}: {
container: Dockerode.ContainerInfo;
selected: Dockerode.ContainerInfo[];
setSelected: (containers: Dockerode.ContainerInfo[]) => void;
iconsData: NormalizedIconRepositoryResult[];
}) {
const containerName = container.Names[0].replace('/', '');
const isSelected = selected.includes(container);
// Example image : linuxserver.io/sonarr:latest
// Remove the slashes
const imageParsed = container.Image.split('/');
// Remove the version
const image = imageParsed[imageParsed.length - 1].split(':')[0];
const foundIcon = iconsData
.flatMap((repository) =>
repository.entries.map((entry) => ({
...entry,
repository: repository.name,
}))
)
.find((entry) => entry.name.toLowerCase().includes(image.toLowerCase()));
return (
<motion.div
whileHover={{ scale: 1.1 }}
style={{
cursor: 'pointer',
}}
onClick={() =>
setSelected(isSelected ? selected.filter((c) => c !== container) : [...selected, container])
}
>
<Card style={{ height: '100%' }} shadow="sm" radius="md" withBorder>
<Stack justify="space-between" style={{ height: '100%' }}>
<Group
style={{
alignSelf: 'flex-end',
}}
>
<ContainerState state={container.State} />
<Checkbox radius="xl" checked={isSelected} size="sm" />
</Group>
<Center>
<Image
withPlaceholder
h={60}
maw={60}
w={60}
src={foundIcon?.url ?? `https://placehold.co/60x60?text=${containerName}`}
/>
</Center>
<Stack spacing={0}>
<Text lineClamp={1} align="center">
{containerName}
</Text>
{container.Image && (
<Text
size="xs"
style={{ overflow: 'hidden', textOverflow: 'elipsis' }}
align="center"
color="dimmed"
>
{container.Image}
</Text>
)}
</Stack>
</Stack>
</Card>
</motion.div>
);
}
function findIconForContainer(
container: Dockerode.ContainerInfo,
iconsData: NormalizedIconRepositoryResult[]
) {
const imageParsed = container.Image.split('/');
// Remove the version
const image = imageParsed[imageParsed.length - 1].split(':')[0];
const foundIcon = iconsData
.flatMap((repository) =>
repository.entries.map((entry) => ({
...entry,
repository: repository.name,
}))
)
.find((entry) => entry.name.toLowerCase().includes(image.toLowerCase()));
return foundIcon;
}
export default function ImportFromDockerModal({ onClickBack }: { onClickBack: () => void }) {
const { data, isLoading } = api.docker.containers.useQuery(undefined, {});
const { data: iconsData } = api.icon.all.useQuery();
const { config } = useConfigContext();
const { mutateAsync, isLoading: mutationIsLoading } =
api.boards.addAppsForContainers.useMutation();
const [selected, setSelected] = useState<Dockerode.ContainerInfo[]>([]);
if (isLoading || !data || !iconsData) return <WidgetLoading />;
return (
<Stack m="sm">
<SelectorBackArrow onClickBack={onClickBack} />
<Grid>
{data?.map((container) => (
<Grid.Col xs={12} sm={4} md={3}>
<DockerDispaly
selected={selected}
setSelected={setSelected}
iconsData={iconsData}
container={container}
/>
</Grid.Col>
))}
</Grid>
<Button
loading={mutationIsLoading}
style={{
zIndex: 1000,
}}
pos={'sticky'}
bottom={10}
disabled={selected.length === 0}
onClick={async () => {
mutateAsync({
apps: selected.map((container) => ({
name: (container.Names.at(0) ?? 'App').replace('/', ''),
port: container.Ports.at(0)?.PublicPort,
icon: findIconForContainer(container, iconsData)?.url,
})),
boardName: config?.configProperties.name!,
}).then(() => {
//TODO: Reload config
closeAllModals();
notifications.show({
title: 'Success',
message: 'Containers added to dashboard',
color: 'green',
});
});
}}
>
Add {selected.length} container{selected.length > 1 && 's'} to dashboard
</Button>
</Stack>
);
}

View File

@@ -1,8 +1,9 @@
import { Group, Space, Stack, Text, UnstyledButton } from '@mantine/core';
import { closeModal } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconBox, IconBoxAlignTop, IconStack } from '@tabler/icons-react';
import { IconBox, IconBoxAlignTop, IconBrandDocker, IconStack } from '@tabler/icons-react';
import { motion } from 'framer-motion';
import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
@@ -18,17 +19,19 @@ import { useStyles } from '../Shared/styles';
interface AvailableElementTypesProps {
modalId: string;
onOpenIntegrations: () => void;
onOpenStaticElements: () => void;
onOpenDocker: () => void;
}
export const AvailableElementTypes = ({
modalId,
onOpenIntegrations: onOpenWidgets,
onOpenStaticElements,
onOpenDocker,
}: AvailableElementTypesProps) => {
const { t } = useTranslation('layout/element-selector/selector');
const { config, name: configName } = useConfigContext();
const { updateConfig } = useConfigStore();
const { data } = useSession();
const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
const onClickCreateCategory = async () => {
@@ -96,6 +99,13 @@ export const AvailableElementTypes = ({
});
}}
/>
{data && data.user.isAdmin && (
<ElementItem
name={t('importFromDocker')}
icon={<IconBrandDocker size={40} strokeWidth={1.3} />}
onClick={onOpenDocker}
/>
)}
<ElementItem
name={t('widgets')}
icon={<IconStack size={40} strokeWidth={1.3} />}

View File

@@ -1,34 +0,0 @@
import { Container, Grid, Text } from '@mantine/core';
import { IconCursorText } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { GenericAvailableElementType } from '../Shared/GenericElementType';
import { SelectorBackArrow } from '../Shared/SelectorBackArrow';
interface AvailableStaticTypesProps {
onClickBack: () => void;
}
export const AvailableStaticTypes = ({ onClickBack }: AvailableStaticTypesProps) => {
const { t } = useTranslation('layout/element-selector/selector');
return (
<>
<SelectorBackArrow onClickBack={onClickBack} />
<Text mb="md" color="dimmed">
Static elements provide you additional control over your dashboard. They are static, because
they don&apos;t integrate with any apps and their content never changes.
</Text>
<Grid grow>
{/*
<GenericAvailableElementType
name="Static Text"
description="Display a fixed string on your dashboard"
image={IconCursorText}
handleAddition={async () => {}}
/> */}
</Grid>
</>
);
};

View File

@@ -1,4 +1,4 @@
import { Grid, Text } from '@mantine/core';
import { Grid, Stack, Text } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import widgets from '../../../../../../widgets';
@@ -14,7 +14,7 @@ export const AvailableIntegrationElements = ({
}: AvailableIntegrationElementsProps) => {
const { t } = useTranslation('layout/element-selector/selector');
return (
<>
<Stack m="sm">
<SelectorBackArrow onClickBack={onClickBack} />
<Text mb="md" color="dimmed">
@@ -26,6 +26,6 @@ export const AvailableIntegrationElements = ({
<WidgetElementType key={k} id={k} image={v.icon} widget={v} />
))}
</Grid>
</>
</Stack>
);
};

View File

@@ -2,11 +2,11 @@ import { ContextModalProps } from '@mantine/modals';
import { useState } from 'react';
import { AvailableElementTypes } from './Components/Overview/AvailableElementsOverview';
import { AvailableStaticTypes } from './Components/StaticElementsTab/AvailableStaticElementsTab';
import { AvailableIntegrationElements } from './Components/WidgetsTab/AvailableWidgetsTab';
import ImportFromDockerModal from './Components/DockerImportModal';
export const SelectElementModal = ({ context, id }: ContextModalProps) => {
const [activeTab, setActiveTab] = useState<undefined | 'integrations' | 'static_elements'>();
const [activeTab, setActiveTab] = useState<undefined | 'integrations' | 'dockerImport'>();
switch (activeTab) {
case undefined:
@@ -14,13 +14,13 @@ export const SelectElementModal = ({ context, id }: ContextModalProps) => {
<AvailableElementTypes
modalId={id}
onOpenIntegrations={() => setActiveTab('integrations')}
onOpenStaticElements={() => setActiveTab('static_elements')}
onOpenDocker={() => setActiveTab('dockerImport')}
/>
);
case 'integrations':
return <AvailableIntegrationElements onClickBack={() => setActiveTab(undefined)} />;
case 'static_elements':
return <AvailableStaticTypes onClickBack={() => setActiveTab(undefined)} />;
case 'dockerImport':
return <ImportFromDockerModal onClickBack={() => setActiveTab(undefined)} />;
default:
/* default to the main selection tab */
setActiveTab(undefined);

View File

@@ -94,7 +94,7 @@ export default function ContainerActionBar({
color="indigo"
variant="light"
radius="md"
disabled={selected.length !== 1}
disabled={selected.length < 1}
onClick={() => openDockerSelectBoardModal({ containers: selected })}
>
{t('actionBar.addToHomarr.title')}

View File

@@ -2,6 +2,7 @@ import {
Badge,
Checkbox,
Group,
Image,
ScrollArea,
Table,
Text,
@@ -13,6 +14,7 @@ import { IconSearch } from '@tabler/icons-react';
import Dockerode, { ContainerInfo } from 'dockerode';
import { useTranslation } from 'next-i18next';
import { Dispatch, SetStateAction, useMemo, useState } from 'react';
import { api } from '~/utils/api';
import { MIN_WIDTH_MOBILE } from '../../../../constants/constants';
import ContainerState from './ContainerState';
@@ -117,6 +119,22 @@ type RowProps = {
const Row = ({ container, selected, toggleRow, width }: RowProps) => {
const { t } = useTranslation('modules/docker');
const { classes, cx } = useStyles();
const { data: iconsData } = api.icon.all.useQuery();
if (!iconsData) return null;
const containerName = container.Names[0].replace('/', '');
// Example image : linuxserver.io/sonarr:latest
// Remove the slashes
const imageParsed = container.Image.split('/');
// Remove the version
const image = imageParsed[imageParsed.length - 1].split(':')[0];
const foundIcon = iconsData
.flatMap((repository) =>
repository.entries.map((entry) => ({
...entry,
repository: repository.name,
}))
)
.find((entry) => entry.name.toLowerCase().includes(image.toLowerCase()));
return (
<tr className={cx({ [classes.rowSelected]: selected })}>
@@ -124,9 +142,12 @@ const Row = ({ container, selected, toggleRow, width }: RowProps) => {
<Checkbox checked={selected} onChange={() => toggleRow(container)} transitionDuration={0} />
</td>
<td>
<Group noWrap>
<Image withPlaceholder src={foundIcon?.url} alt={image} width={30} height={30} />
<Text size="lg" weight={600}>
{container.Names[0].replace('/', '')}
{containerName}
</Text>
</Group>
</td>
{width > MIN_WIDTH_MOBILE && (
<td>

View File

@@ -26,7 +26,7 @@ export default function OnboardPage({
const [onboardingSteps, { open: showOnboardingSteps }] = useDisclosure(false);
const isUpgradeFromSchemaOne = configSchemaVersions.includes(1);
const isUpgradeFromSchemaOne = false;
return (
<>
@@ -81,12 +81,12 @@ export default function OnboardPage({
}
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const userCount = await getTotalUserCountAsync();
if (userCount >= 1) {
return {
notFound: true,
};
}
// const userCount = await getTotalUserCountAsync();
// if (userCount >= 1) {
// return {
// notFound: true,
// };
// }
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
const configs = files.map((file) => getConfig(file));

View File

@@ -41,6 +41,7 @@ export const boardRouter = createTRPCRouter({
apps: z.array(
z.object({
name: z.string(),
icon: z.string().optional(),
port: z.number().optional(),
})
),
@@ -71,6 +72,10 @@ export const boardRouter = createTRPCRouter({
...defaultApp,
name: container.name,
url: address,
appearance: {
...defaultApp.appearance,
icon: container.icon,
},
behaviour: {
...defaultApp.behaviour,
externalUrl: address,

View File

@@ -1,13 +1,11 @@
import { GitHubIconsRepository } from '~/tools/server/images/github-icons-repository';
import { JsdelivrIconsRepository } from '~/tools/server/images/jsdelivr-icons-repository';
import { LocalIconsRepository } from '~/tools/server/images/local-icons-repository';
import { UnpkgIconsRepository } from '~/tools/server/images/unpkg-icons-repository';
import { GitHubIconsRepository } from '~/tools/server/images/github-icons-repository';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const iconRouter = createTRPCRouter({
all: publicProcedure.query(async () => {
const respositories = [
const respositories = [
new LocalIconsRepository(),
new GitHubIconsRepository(
GitHubIconsRepository.walkxcode,
@@ -29,7 +27,10 @@ export const iconRouter = createTRPCRouter({
'Homelab Svg Assets',
'loganmarchione on GitHub (MIT)'
),
];
];
export const iconRouter = createTRPCRouter({
all: publicProcedure.query(async () => {
const fetches = respositories.map((rep) => rep.fetch());
const data = await Promise.all(fetches);
return data;

View File

@@ -7,10 +7,10 @@ import { OriginalLanguage, Result } from '~/modules/overseerr/SearchResult';
import { TvShowResult } from '~/modules/overseerr/TvShow';
import { getConfig } from '~/tools/config/getConfig';
import { createTRPCRouter, publicProcedure } from '../trpc';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
export const overseerrRouter = createTRPCRouter({
search: publicProcedure
search: adminProcedure
.input(
z.object({
configName: z.string(),