toggleRow(container)} transitionDuration={0} />
|
-
- {container.Names[0].replace('/', '')}
-
+
+
+
+ {containerName}
+
+
|
{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..688c6796c 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,9 @@ import { IconCheck, IconX } from '@tabler/icons-react';
import { ContainerInfo } from 'dockerode';
import { Trans, useTranslation } from 'next-i18next';
import { z } from 'zod';
+import { useConfigContext } from '~/config/provider';
+import { useConfigStore } from '~/config/store';
+import { generateDefaultApp } from '~/tools/shared/app';
import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
@@ -14,7 +17,7 @@ const dockerSelectBoardSchema = z.object({
});
type InnerProps = {
- containers: ContainerInfo[];
+ containers: (ContainerInfo & { icon?: string })[];
};
type FormType = z.infer;
@@ -22,13 +25,18 @@ export const DockerSelectBoardModal = ({ id, innerProps }: ContextModalProps store.updateConfig);
const handleSubmit = async (values: FormType) => {
+ const newApps = innerProps.containers.map((container) => ({
+ name: (container.Names.at(0) ?? 'App').replace('/', ''),
+ port: container.Ports.at(0)?.PublicPort,
+ icon: container.icon,
+ }));
await mutateAsync(
{
- apps: innerProps.containers.map((container) => ({
- name: (container.Names.at(0) ?? 'App').replace('/', ''),
- port: container.Ports.at(0)?.PublicPort,
- })),
+ apps: newApps,
boardName: values.board,
},
{
@@ -39,7 +47,21 @@ export const DockerSelectBoardModal = ({ id, innerProps }: ContextModalProps,
color: 'green',
});
-
+ updateConfig(configName!, (config) => {
+ const lowestWrapper = config?.wrappers.sort((a, b) => a.position - b.position)[0];
+ const defaultApp = generateDefaultApp(lowestWrapper.id);
+ return {
+ ...config,
+ apps: [
+ ...config.apps,
+ ...newApps.map((app) => ({
+ ...defaultApp,
+ ...app,
+ wrapperId: lowestWrapper.id,
+ })),
+ ],
+ };
+ });
modals.close(id);
},
onError: () => {
@@ -117,5 +139,5 @@ export const openDockerSelectBoardModal = (innerProps: InnerProps) => {
),
innerProps,
});
- umami.track('Add to homarr modal')
+ umami.track('Add to homarr modal');
};
diff --git a/src/components/layout/Templates/BoardLayout.tsx b/src/components/layout/Templates/BoardLayout.tsx
index 3e82ea775..81f4ed712 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 { 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';
@@ -20,14 +30,15 @@ import { MainLayout } from './MainLayout';
type BoardLayoutProps = {
children: React.ReactNode;
+ isDockerEnabled?: boolean;
};
-export const BoardLayout = ({ children }: BoardLayoutProps) => {
+export const BoardLayout = ({ children, isDockerEnabled = false }: BoardLayoutProps) => {
const { config } = useConfigContext();
const { data: session } = useSession();
return (
- }>
+ }>
{children}
@@ -36,7 +47,7 @@ export const BoardLayout = ({ children }: BoardLayoutProps) => {
);
};
-export const HeaderActions = () => {
+export const HeaderActions = ({isDockerEnabled = false} : { isDockerEnabled: boolean}) => {
const { data: sessionData } = useSession();
if (!sessionData?.user?.isAdmin) return null;
@@ -44,11 +55,55 @@ export const HeaderActions = () => {
return (
<>
+ {isDockerEnabled && }
>
);
};
+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..661db269c 100644
--- a/src/pages/board/index.tsx
+++ b/src/pages/board/index.tsx
@@ -1,8 +1,9 @@
-import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
+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,14 +11,23 @@ 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,
+ isDockerEnabled,
+ initialContainers,
}: InferGetServerSidePropsType) {
useInitConfig(initialConfig);
+ const { data } = api.docker.containers.useQuery(undefined, {
+ initialData: initialContainers ?? undefined,
+ enabled: isDockerEnabled,
+ cacheTime: 60 * 1000 * 5,
+ staleTime: 60 * 1000 * 1,
+ });
return (
-
+
);
@@ -28,33 +38,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) {
+
+ }
return {
props: {
config,
primaryColor: config.settings.customization.colors.primary,
secondaryColor: config.settings.customization.colors.secondary,
primaryShade: config.settings.customization.colors.shade,
+ isDockerEnabled: containers != undefined,
+ initialContainers: containers ?? null,
...translations,
},
};
diff --git a/src/server/api/routers/app.ts b/src/server/api/routers/app.ts
index e7c3f5e23..4ec416c71 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';
@@ -18,7 +17,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 +28,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 05c74c2e4..72bab7eaf 100644
--- a/src/server/api/routers/board.ts
+++ b/src/server/api/routers/board.ts
@@ -41,6 +41,7 @@ export const boardRouter = createTRPCRouter({
apps: z.array(
z.object({
name: z.string(),
+ icon: z.string().optional(),
port: z.number().optional(),
})
),
@@ -54,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 = {
@@ -66,11 +66,14 @@ export const boardRouter = createTRPCRouter({
const address = container.port
? `http://localhost:${container.port}`
: 'http://localhost';
-
return {
...defaultApp,
name: container.name,
url: address,
+ appearance: {
+ ...defaultApp.appearance,
+ iconUrl: container.icon,
+ },
behaviour: {
...defaultApp.behaviour,
externalUrl: address,
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 a5b6c2493..02e8630b6 100644
--- a/src/server/api/routers/icon.ts
+++ b/src/server/api/routers/icon.ts
@@ -1,36 +1,37 @@
+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 IconRespositories = [
+ new LocalIconsRepository(),
+ new GitHubIconsRepository(
+ GitHubIconsRepository.walkxcode,
+ 'Walkxcode Dashboard Icons',
+ 'Walkxcode on Github'
+ ),
+ new UnpkgIconsRepository(
+ UnpkgIconsRepository.tablerRepository,
+ 'Tabler Icons',
+ 'Tabler Icons - GitHub (MIT)'
+ ),
+ new JsdelivrIconsRepository(
+ JsdelivrIconsRepository.papirusRepository,
+ 'Papirus Icons',
+ 'Papirus Development Team on GitHub (Apache 2.0)'
+ ),
+ new JsdelivrIconsRepository(
+ JsdelivrIconsRepository.homelabSvgAssetsRepository,
+ 'Homelab Svg Assets',
+ 'loganmarchione on GitHub (MIT)'
+ ),
+];
+
export const iconRouter = createTRPCRouter({
all: publicProcedure.query(async () => {
- const respositories = [
- new LocalIconsRepository(),
- new GitHubIconsRepository(
- GitHubIconsRepository.walkxcode,
- 'Walkxcode Dashboard Icons',
- 'Walkxcode on Github'
- ),
- new UnpkgIconsRepository(
- UnpkgIconsRepository.tablerRepository,
- 'Tabler Icons',
- 'Tabler Icons - GitHub (MIT)'
- ),
- new JsdelivrIconsRepository(
- JsdelivrIconsRepository.papirusRepository,
- 'Papirus Icons',
- 'Papirus Development Team on GitHub (Apache 2.0)'
- ),
- new JsdelivrIconsRepository(
- JsdelivrIconsRepository.homelabSvgAssetsRepository,
- 'Homelab Svg Assets',
- 'loganmarchione on GitHub (MIT)'
- ),
- ];
- 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/server/api/routers/overseerr.ts b/src/server/api/routers/overseerr.ts
index 222082603..fc778c433 100644
--- a/src/server/api/routers/overseerr.ts
+++ b/src/server/api/routers/overseerr.ts
@@ -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 { protectedProcedure, createTRPCRouter, publicProcedure } from '../trpc';
export const overseerrRouter = createTRPCRouter({
- search: publicProcedure
+ search: protectedProcedure
.input(
z.object({
configName: z.string(),
diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts
index f9af2b593..22092ad53 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',