diff --git a/package.json b/package.json index 3724f70c2..7fbaf9707 100644 --- a/package.json +++ b/package.json @@ -233,4 +233,4 @@ ] } } -} \ No newline at end of file +} diff --git a/src/components/Dashboard/Modals/SelectElement/Components/DockerImportModal.tsx b/src/components/Dashboard/Modals/SelectElement/Components/DockerImportModal.tsx deleted file mode 100644 index 81f1a8be6..000000000 --- a/src/components/Dashboard/Modals/SelectElement/Components/DockerImportModal.tsx +++ /dev/null @@ -1,167 +0,0 @@ -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 ( - - setSelected(isSelected ? selected.filter((c) => c !== container) : [...selected, container]) - } - > - - - - - - - -
- -
- - - {containerName} - - {container.Image && ( - - {container.Image} - - )} - -
-
-
- ); -} - -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([]); - - if (isLoading || !data || !iconsData) return ; - return ( - - - - {data?.map((container) => ( - - - - ))} - - - - ); -} diff --git a/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx index 72ec3cce8..3c7ce5262 100644 --- a/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx +++ b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx @@ -1,7 +1,7 @@ import { Group, Space, Stack, Text, UnstyledButton } from '@mantine/core'; import { closeModal } from '@mantine/modals'; import { showNotification } from '@mantine/notifications'; -import { IconBox, IconBoxAlignTop, IconBrandDocker, IconStack } from '@tabler/icons-react'; +import { IconBox, IconBoxAlignTop, IconStack } from '@tabler/icons-react'; import { motion } from 'framer-motion'; import { useSession } from 'next-auth/react'; import { useTranslation } from 'next-i18next'; @@ -19,13 +19,11 @@ import { useStyles } from '../Shared/styles'; interface AvailableElementTypesProps { modalId: string; onOpenIntegrations: () => void; - onOpenDocker: () => void; } export const AvailableElementTypes = ({ modalId, onOpenIntegrations: onOpenWidgets, - onOpenDocker, }: AvailableElementTypesProps) => { const { t } = useTranslation('layout/element-selector/selector'); const { config, name: configName } = useConfigContext(); @@ -99,13 +97,6 @@ export const AvailableElementTypes = ({ }); }} /> - {data && data.user.isAdmin && ( - } - onClick={onOpenDocker} - /> - )} } diff --git a/src/components/Dashboard/Modals/SelectElement/SelectElementModal.tsx b/src/components/Dashboard/Modals/SelectElement/SelectElementModal.tsx index 3727b00f8..7a1cd5648 100644 --- a/src/components/Dashboard/Modals/SelectElement/SelectElementModal.tsx +++ b/src/components/Dashboard/Modals/SelectElement/SelectElementModal.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { AvailableElementTypes } from './Components/Overview/AvailableElementsOverview'; import { AvailableIntegrationElements } from './Components/WidgetsTab/AvailableWidgetsTab'; -import ImportFromDockerModal from './Components/DockerImportModal'; export const SelectElementModal = ({ context, id }: ContextModalProps) => { const [activeTab, setActiveTab] = useState(); @@ -14,13 +13,10 @@ export const SelectElementModal = ({ context, id }: ContextModalProps) => { setActiveTab('integrations')} - onOpenDocker={() => setActiveTab('dockerImport')} /> ); case 'integrations': return setActiveTab(undefined)} />; - case 'dockerImport': - return setActiveTab(undefined)} />; default: /* default to the main selection tab */ setActiveTab(undefined); diff --git a/src/components/Manage/Tools/Docker/ContainerActionBar.tsx b/src/components/Manage/Tools/Docker/ContainerActionBar.tsx index 2fa0f591b..cd35d9681 100644 --- a/src/components/Manage/Tools/Docker/ContainerActionBar.tsx +++ b/src/components/Manage/Tools/Docker/ContainerActionBar.tsx @@ -16,7 +16,7 @@ import { RouterInputs, api } from '~/utils/api'; import { openDockerSelectBoardModal } from './docker-select-board.modal'; export interface ContainerActionBarProps { - selected: Dockerode.ContainerInfo[]; + selected: (Dockerode.ContainerInfo & { icon?: string })[]; reload: () => void; isLoading: boolean; } @@ -127,13 +127,7 @@ const useDockerActionMutation = () => { { action, id: container.Id }, { onSuccess: () => { - notifications.update({ - id: container.Id, - title: containerName, - message: `${t(`actions.${action}.end`)} ${containerName}`, - icon: , - autoClose: 2000, - }); + notifications.cleanQueue(); }, onError: (err) => { notifications.update({ diff --git a/src/components/Manage/Tools/Docker/ContainerTable.tsx b/src/components/Manage/Tools/Docker/ContainerTable.tsx index 23ecc711a..aec474ea8 100644 --- a/src/components/Manage/Tools/Docker/ContainerTable.tsx +++ b/src/components/Manage/Tools/Docker/ContainerTable.tsx @@ -14,7 +14,6 @@ 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'; @@ -98,6 +97,7 @@ export default function ContainerTable({ void; width: number; + icon?: string; }; -const Row = ({ container, selected, toggleRow, width }: RowProps) => { +const Row = ({ icon, 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 ( @@ -143,7 +129,7 @@ const Row = ({ container, selected, toggleRow, width }: RowProps) => { - {image} + {containerName} @@ -151,7 +137,7 @@ const Row = ({ container, selected, toggleRow, width }: RowProps) => { {width > MIN_WIDTH_MOBILE && ( - {container.Image} + {container.Image.slice(0, 25)} )} {width > MIN_WIDTH_MOBILE && ( diff --git a/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx b/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx index 8eabedb7d..63b4da364 100644 --- a/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx +++ b/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core'; +import { Button, Group, Select, Stack, Text, Title } from '@mantine/core'; import { useForm } from '@mantine/form'; import { ContextModalProps, modals } from '@mantine/modals'; import { showNotification } from '@mantine/notifications'; @@ -6,6 +6,7 @@ import { IconCheck, IconX } from '@tabler/icons-react'; import { ContainerInfo } from 'dockerode'; import { Trans, useTranslation } from 'next-i18next'; import { z } from 'zod'; +import { useConfigStore } from '~/config/store'; import { api } from '~/utils/api'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; @@ -14,7 +15,7 @@ const dockerSelectBoardSchema = z.object({ }); type InnerProps = { - containers: ContainerInfo[]; + containers: (ContainerInfo & { icon?: string })[]; }; type FormType = z.infer; @@ -22,12 +23,14 @@ export const DockerSelectBoardModal = ({ id, innerProps }: ContextModalProps { await mutateAsync( { apps: innerProps.containers.map((container) => ({ name: (container.Names.at(0) ?? 'App').replace('/', ''), port: container.Ports.at(0)?.PublicPort, + icon: container.icon, })), boardName: values.board, }, @@ -39,7 +42,7 @@ export const DockerSelectBoardModal = ({ id, innerProps }: ContextModalProps, color: 'green', }); - + //TODO: Update config or reload it from server modals.close(id); }, onError: () => { @@ -117,5 +120,9 @@ export const openDockerSelectBoardModal = (innerProps: InnerProps) => { ), innerProps, }); - umami.track('Add to homarr modal') + umami.track('Add to homarr modal'); }; +function uuidv4(): any { + throw new Error('Function not implemented.'); +} + diff --git a/src/components/layout/Templates/BoardLayout.tsx b/src/components/layout/Templates/BoardLayout.tsx index 3e82ea775..4f165b12c 100644 --- a/src/components/layout/Templates/BoardLayout.tsx +++ b/src/components/layout/Templates/BoardLayout.tsx @@ -1,16 +1,26 @@ -import { Button, Global, Text, Title, Tooltip, clsx } from '@mantine/core'; -import { useHotkeys, useWindowEvent } from '@mantine/hooks'; +import { Button, Global, Modal, Stack, Text, Title, Tooltip, clsx } from '@mantine/core'; +import { useDisclosure, useHotkeys, useWindowEvent } from '@mantine/hooks'; import { openContextModal } from '@mantine/modals'; import { hideNotification, showNotification } from '@mantine/notifications'; -import { IconApps, IconEditCircle, IconEditCircleOff, IconSettings } from '@tabler/icons-react'; +import { + IconApps, + IconBrandDocker, + IconEditCircle, + IconEditCircleOff, + IconSettings, +} from '@tabler/icons-react'; import Consola from 'consola'; +import { ContainerInfo } from 'dockerode'; import { useSession } from 'next-auth/react'; import { Trans, useTranslation } from 'next-i18next'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { env } from 'process'; +import { useEffect, useState } from 'react'; import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore'; import { useNamedWrapperColumnCount } from '~/components/Dashboard/Wrappers/gridstack/store'; +import ContainerActionBar from '~/components/Manage/Tools/Docker/ContainerActionBar'; +import ContainerTable from '~/components/Manage/Tools/Docker/ContainerTable'; import { BoardHeadOverride } from '~/components/layout/Meta/BoardHeadOverride'; import { HeaderActionButton } from '~/components/layout/header/ActionButton'; import { useConfigContext } from '~/config/provider'; @@ -44,11 +54,55 @@ export const HeaderActions = () => { return ( <> + ); }; +const DockerButton = () => { + const [selection, setSelection] = useState<(ContainerInfo & { icon?: string })[]>([]); + const [opened, { open, close, toggle }] = useDisclosure(false); + useHotkeys([['mod+B', toggle]]); + + const { data, refetch, isRefetching } = api.docker.containers.useQuery(undefined, { + cacheTime: 60 * 1000 * 5, + staleTime: 60 * 1000 * 1, + }); + const { t } = useTranslation('tools/docker'); + const reload = () => { + refetch(); + setSelection([]); + }; + + return ( + <> + + + + + + + + + + + + + ); +}; + const CustomizeBoardButton = () => { const { name } = useConfigContext(); const { t } = useTranslation('boards/common'); diff --git a/src/pages/board/index.tsx b/src/pages/board/index.tsx index 9822d125b..627baf176 100644 --- a/src/pages/board/index.tsx +++ b/src/pages/board/index.tsx @@ -1,8 +1,10 @@ -import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import Consola from 'consola'; +import { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next'; import { SSRConfig } from 'next-i18next'; import { Dashboard } from '~/components/Dashboard/Dashboard'; import { BoardLayout } from '~/components/layout/Templates/BoardLayout'; import { useInitConfig } from '~/config/init'; +import { dockerRouter } from '~/server/api/routers/docker/router'; import { getServerAuthSession } from '~/server/auth'; import { getDefaultBoardAsync } from '~/server/db/queries/userSettings'; import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; @@ -10,11 +12,20 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; import { boardNamespaces } from '~/tools/server/translation-namespaces'; import { ConfigType } from '~/types/config'; +import { api } from '~/utils/api'; export default function BoardPage({ config: initialConfig, + dockerIsConfigured, + initialContainers, }: InferGetServerSidePropsType) { useInitConfig(initialConfig); + const { data } = api.docker.containers.useQuery(undefined, { + initialData: initialContainers, + enabled: dockerIsConfigured, + cacheTime: 60 * 1000 * 5, + staleTime: 60 * 1000 * 1, + }); return ( @@ -28,33 +39,45 @@ type BoardGetServerSideProps = { _nextI18Next?: SSRConfig['_nextI18Next']; }; -export const getServerSideProps: GetServerSideProps = async (ctx) => { - const session = await getServerAuthSession(ctx); +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const session = await getServerAuthSession(context); const boardName = await getDefaultBoardAsync(session?.user?.id, 'default'); const translations = await getServerSideTranslations( boardNamespaces, - ctx.locale, - ctx.req, - ctx.res + context.locale, + context.req, + context.res ); const config = await getFrontendConfig(boardName); const result = checkForSessionOrAskForLogin( - ctx, + context, session, () => config.settings.access.allowGuests || session?.user != undefined ); if (result) { return result; } - + const caller = dockerRouter.createCaller({ + session: session, + cookies: context.req.cookies, + }); + let containers = undefined; + // Fetch containers if user is admin, otherwise we don't need them + try { + if (session?.user.isAdmin == true) containers = await caller.containers(); + } catch (error) { + Consola.error(`The docker integration failed with the following error: ${error}`); + } return { props: { config, primaryColor: config.settings.customization.colors.primary, secondaryColor: config.settings.customization.colors.secondary, primaryShade: config.settings.customization.colors.shade, + dockerIsConfigured: containers != undefined, + initialContainers: containers, ...translations, }, }; diff --git a/src/server/api/routers/app.ts b/src/server/api/routers/app.ts index e7c3f5e23..ddfd49a8b 100644 --- a/src/server/api/routers/app.ts +++ b/src/server/api/routers/app.ts @@ -1,7 +1,6 @@ import { TRPCError } from '@trpc/server'; -import axios, { AxiosError } from 'axios'; +import { AxiosError } from 'axios'; import Consola from 'consola'; -import https from 'https'; import { z } from 'zod'; import { isStatusOk } from '~/components/Dashboard/Tiles/Apps/AppPing'; import { getConfig } from '~/tools/config/getConfig'; @@ -9,6 +8,12 @@ import { AppType } from '~/types/app'; import { createTRPCRouter, publicProcedure } from '../trpc'; +// const agent = new https.Agent({ rejectUnauthorized: false }); +// const getCacheResponse = unstable_cache( +// async (app) => await axios.get(app.url, { httpsAgent: agent, timeout: 10000 }), +// ['app-id'] +// ); + export const appRouter = createTRPCRouter({ ping: publicProcedure .input( @@ -18,7 +23,6 @@ export const appRouter = createTRPCRouter({ }) ) .query(async ({ input }) => { - const agent = new https.Agent({ rejectUnauthorized: false }); const config = getConfig(input.configName); const app = config.apps.find((app) => app.id === input.id); @@ -30,8 +34,16 @@ export const appRouter = createTRPCRouter({ message: `App ${input.id} was not found`, }); } - const res = await axios - .get(app.url, { httpsAgent: agent, timeout: 10000 }) + + const res = await fetch(app.url, { + method: 'GET', + cache: 'force-cache', + headers: { + // Cache for 5 minutes + 'Cache-Control': 'max-age=300', + 'Content-Type': 'application/json', + }, + }) .then((response) => ({ status: response.status, statusText: response.statusText, diff --git a/src/server/api/routers/board.ts b/src/server/api/routers/board.ts index 5c03d0dcc..72bab7eaf 100644 --- a/src/server/api/routers/board.ts +++ b/src/server/api/routers/board.ts @@ -55,7 +55,6 @@ export const boardRouter = createTRPCRouter({ }); } const config = await getConfig(input.boardName); - const lowestWrapper = config?.wrappers.sort((a, b) => a.position - b.position)[0]; const newConfig = { @@ -67,14 +66,13 @@ export const boardRouter = createTRPCRouter({ const address = container.port ? `http://localhost:${container.port}` : 'http://localhost'; - return { ...defaultApp, name: container.name, url: address, appearance: { ...defaultApp.appearance, - icon: container.icon, + iconUrl: container.icon, }, behaviour: { ...defaultApp.behaviour, diff --git a/src/server/api/routers/docker/router.ts b/src/server/api/routers/docker/router.ts index 44b2a35da..cccd7362b 100644 --- a/src/server/api/routers/docker/router.ts +++ b/src/server/api/routers/docker/router.ts @@ -3,6 +3,7 @@ import Dockerode from 'dockerode'; import { z } from 'zod'; import { adminProcedure, createTRPCRouter } from '../../trpc'; +import { IconRespositories } from '../icon'; import DockerSingleton from './DockerSingleton'; const dockerActionSchema = z.enum(['remove', 'start', 'stop', 'restart']); @@ -12,7 +13,27 @@ export const dockerRouter = createTRPCRouter({ try { const docker = new Dockerode({}); const containers = await docker.listContainers({ all: true }); - return containers; + const fetches = IconRespositories.map((rep) => rep.fetch()); + const data = await Promise.all(fetches); + const returnedData = containers.map((container) => { + const imageParsed = container.Image.split('/'); + // Remove the version + const image = imageParsed[imageParsed.length - 1].split(':')[0]; + const foundIcon = data + .flatMap((repository) => + repository.entries.map((entry) => ({ + ...entry, + repository: repository.name, + })) + ) + .find((entry) => entry.name.toLowerCase().includes(image.toLowerCase())); + + return { + ...container, + icon: foundIcon?.url ?? '/public/imgs/logo/logo.svg' + }; + }); + return returnedData; } catch (err) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', diff --git a/src/server/api/routers/icon.ts b/src/server/api/routers/icon.ts index 2ad4d2ad7..02e8630b6 100644 --- a/src/server/api/routers/icon.ts +++ b/src/server/api/routers/icon.ts @@ -5,7 +5,7 @@ import { UnpkgIconsRepository } from '~/tools/server/images/unpkg-icons-reposito import { createTRPCRouter, publicProcedure } from '../trpc'; -const respositories = [ +export const IconRespositories = [ new LocalIconsRepository(), new GitHubIconsRepository( GitHubIconsRepository.walkxcode, @@ -31,7 +31,7 @@ const respositories = [ export const iconRouter = createTRPCRouter({ all: publicProcedure.query(async () => { - const fetches = respositories.map((rep) => rep.fetch()); + const fetches = IconRespositories.map((rep) => rep.fetch()); const data = await Promise.all(fetches); return data; }), diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index ff186c616..2141b82ae 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -1,4 +1,5 @@ export const boardNamespaces = [ + 'tools/docker', 'layout/element-selector/selector', 'layout/modals/add-app', 'layout/modals/change-position',