Compare commits

..
Author SHA1 Message Date
Thomas 99e013c1fb Merge branch 'dev' into feat/widget-overlay 2026-06-10 16:54:25 +02:00
homarr-renovate[bot] b3cdab579b chore(deps): update dependency isomorphic-dompurify to ^3.16.0 (#5913)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-10 14:31:02 +00:00
homarr-renovate[bot] 44a0c6ce54 chore(deps): update dependency semver-parser to ^4.2.1 (#5868)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 12:47:00 +00:00
homarr-renovate[bot] 7f05e0325d chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.1 (#5906)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 09:53:16 +00:00
homarr-renovate[bot] f71b145403 chore(deps): update tiptap monorepo to v3.25.0 (#5898)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 05:17:18 +00:00
homarr-renovate[bot] 607fd498f4 chore(deps): update tanstack-query monorepo to ^5.101.0 (#5894)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 00:17:40 +00:00
homarr-renovate[bot] 3f2fb54cdf chore(deps): update mantine monorepo to ^9.3.0 (#5890)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 22:39:48 +00:00
Thomasandhomarr-releases[bot] 69412f2d10 chore(release): 1.64.0 [skip ci] (#5902)
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-08 22:25:40 +02:00
Thomas Camlong 786ad347a0 style(board): round overlay on all sides and hide on mobile
Apply full border-radius to hover overlay panel instead of bottom-only,
and add media query to hide the overlay on screens <= 768px since hover
is not available on touch devices.
2026-06-08 22:23:41 +02:00
Afonso Jorge Ramos f6d5f97933 fix(integrations): support truenas json-rpc api, certificate trust, and api key auth (#5861)
* chore(integrations): add ws dependency for the truenas client

* feat(definitions): allow api key authentication for truenas

* fix(integrations): connect to truenas over trusted-certificate websocket with protocol autodetection

* test(integrations): cover truenas protocol detection and authentication

* refactor(integrations): avoid shorthand index variable in truenas mapping

* feat(integrations): add a websocket url helper to the integration base

* refactor(integrations): extract truenas connection handling into a client
2026-06-08 22:20:42 +02:00
homarr-renovate[bot] a447a9f615 chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.17.0 (#5897)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 14:15:50 +00:00
homarr-renovate[bot] 87d3f6a0a1 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.4 (#5896)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 09:30:40 +00:00
Thomas Camlong 93ce4e4a1b perf(board): lift integration query to SectionContent
Avoid N identical useQuery hooks by fetching integrations once
at the parent and passing them as a prop to each WidgetHoverOverlay.
2026-06-07 10:40:35 +02:00
ajnart 4cf41f2c4c style: auto-fix lint and format 2026-06-07 00:06:03 +00:00
Thomas Camlong ab3c8b4d1f feat(board): mount widget hover overlay on dashboard grid items 2026-06-07 02:05:01 +02:00
Thomas Camlong 78ddc81b15 feat(board): add widget hover overlay component 2026-06-07 02:04:57 +02:00
Thomas Camlong ad98f2d262 feat(widgets): export widget option types for hover metadata 2026-06-07 02:04:52 +02:00
homarr-renovate[bot] f2705eeb57 chore(deps): update dependency oxfmt to ^0.53.0 (#5883)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 23:12:23 +00:00
homarr-renovate[bot] ac37d2c73f chore(deps): update react monorepo (#5886)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 21:13:36 +00:00
homarr-renovate[bot] e00ea624cd chore(deps): update nextjs monorepo to v16.2.7 (#5885)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 19:32:30 +00:00
homarr-renovate[bot] 20b62c42e3 chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.0 (#5878)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 13:54:21 +00:00
homarr-renovate[bot] 59971991c7 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.16 (#5880)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 11:36:28 +00:00
homarr-renovate[bot] a629681ae5 chore(deps): update vitest monorepo to ^4.1.8 (#5879)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 09:34:48 +00:00
Releases Homarr 8a1ead3674 chore(release): 1.64.0 [skip ci] 2026-06-05 17:35:36 +00:00
homarr-releases[bot] 9c05f44916 chore(release): automatic release v1.64.0 2026-06-05 17:34:47 +00:00
119 changed files with 2407 additions and 15290 deletions

No files matched your search

-8
View File
@@ -1,5 +1,4 @@
Dockerfile
Dockerfile.dev
.dockerignore
node_modules
**/node_modules
@@ -15,10 +14,3 @@ apps/nextjs/.next
.env
.env.*
!.env.example
.docker-data
.docker-cache
coverage
.cursor
agent-transcripts
**/*.spec.ts
**/*.test.ts
-4
View File
@@ -72,7 +72,3 @@ en.d.json.ts
# personal agent config
AGENTS.md
.opencode
# local docker dev
.docker-data/
.docker-cache/
+1 -1
View File
@@ -22,7 +22,6 @@
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
@@ -65,6 +64,7 @@
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@xterm/addon-canvas": "catalog:",
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
"babel-plugin-react-compiler": "catalog:",
@@ -6,14 +6,11 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client";
import { clearInitImportMode } from "./import/use-init-import-mode";
export const BackToStart = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => {
clearInitImportMode();
await mutateAsync();
await revalidatePathActionAsync("/init");
};
@@ -1,116 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button, Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
import { FileInfoCard } from "./file-info-card";
export const InitConfigImport = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const [file, setFile] = useState<FileWithPath | null>(null);
const [fileContent, setFileContent] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const hasOpenedRef = useRef(false);
const {
mutate: runPreview,
data: preview,
isPending: previewLoading,
reset: resetPreview,
} = clientApi.import.previewInitialConfigImport.useMutation();
const { mutateAsync, isPending } = clientApi.import.importInitialConfigImport.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
useEffect(() => {
if (hasOpenedRef.current) return;
hasOpenedRef.current = true;
setTimeout(() => inputRef.current?.click(), 100);
}, []);
useEffect(() => {
if (!fileContent) {
resetPreview();
return;
}
runPreview({ content: fileContent });
}, [fileContent, runPreview, resetPreview]);
const importAllowed = preview?.compatibility.status === "compatible";
const handleImport = async () => {
if (!file || !importAllowed) return;
await mutateAsync(
{ content: fileContent },
{
onError() {
showErrorNotification({
title: t("management.page.importExport.importConfig.notification.error.title"),
message: t("management.page.importExport.importConfig.notification.error.message"),
});
},
},
);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0];
if (!selectedFile) return;
void selectedFile.text().then((content) => {
setFileContent(content);
setFile(selectedFile as FileWithPath);
});
};
return (
<Stack w="100%" mb="sm">
<input
ref={inputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
style={{ display: "none" }}
/>
{file && (
<>
<FileInfoCard
file={file}
onRemove={() => {
setFile(null);
setFileContent("");
}}
/>
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
<Button
loading={isPending}
disabled={!importAllowed || previewLoading}
onClick={() => void handleImport()}
>
{t("init.step.import.configImport.action")}
</Button>
</>
)}
{!file && (
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => inputRef.current?.click()}>
{t("init.step.import.configImport.selectFile")}
</Button>
)}
</Stack>
);
};
@@ -11,20 +11,8 @@ import { InitialOldmarrImport } from "@homarr/old-import/components";
import { InitStepCard } from "../../_components/init-step-card";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
import { InitConfigImport } from "./init-config-import";
import { useInitImportMode } from "./use-init-import-mode";
export const InitImport = () => {
const importMode = useInitImportMode();
if (importMode === "config") {
return <InitConfigImport />;
}
return <LegacyInitImport />;
};
const LegacyInitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
@@ -1,30 +0,0 @@
"use client";
import { useSyncExternalStore } from "react";
export type InitImportMode = "legacy" | "config";
const storageKey = "homarr-init-import-mode";
const readMode = (): InitImportMode => {
const stored = sessionStorage.getItem(storageKey);
return stored === "config" ? "config" : "legacy";
};
const subscribe = (onStoreChange: () => void) => {
window.addEventListener("storage", onStoreChange);
return () => window.removeEventListener("storage", onStoreChange);
};
export const setInitImportMode = (mode: InitImportMode) => {
sessionStorage.setItem(storageKey, mode);
window.dispatchEvent(new Event("storage"));
};
export const clearInitImportMode = () => {
sessionStorage.removeItem(storageKey);
window.dispatchEvent(new Event("storage"));
};
export const useInitImportMode = () =>
useSyncExternalStore(subscribe, readMode, () => "legacy" as InitImportMode);
@@ -1,5 +1,5 @@
import { Stack, Text } from "@mantine/core";
import { IconFileImport, IconJson, IconPlayerPlay } from "@tabler/icons-react";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
@@ -23,18 +23,10 @@ export const InitStart = async () => {
</InitStartButton>
<InitStartButton
preferredStep="import"
importMode="legacy"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importOldmarr")}
</InitStartButton>
<InitStartButton
preferredStep="import"
importMode="config"
icon={<IconJson color={getMantineColor("blue", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importConfig")}
</InitStartButton>
</Stack>
</InitStepCard>
);
@@ -7,28 +7,15 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions";
import { clearInitImportMode, setInitImportMode, type InitImportMode } from "../import/use-init-import-mode";
interface InitStartButtonProps {
icon: ReactNode;
preferredStep: OnboardingStep | undefined;
importMode?: InitImportMode;
}
export const InitStartButton = ({
preferredStep,
icon,
children,
importMode,
}: PropsWithChildren<InitStartButtonProps>) => {
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
if (importMode) {
setInitImportMode(importMode);
} else {
clearInitImportMode();
}
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
@@ -2,14 +2,14 @@
import { useCallback } from "react";
import { Menu } from "@mantine/core";
import { IconCopy, IconDeviceMobile, IconDownload, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal, ExportBoardModal } from "@homarr/modals-collection";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -28,10 +28,7 @@ interface BoardCardMenuDropdownProps {
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action" as never) as unknown as (
key: string,
values?: Record<string, unknown>,
) => string;
const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
@@ -39,7 +36,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const { openModal: openExportModal } = useModalAction(ExportBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
@@ -91,15 +87,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
});
}, [board.id, board.name, openDuplicateModal]);
const handleExportBoard = useCallback(() => {
openExportModal({
board: {
id: board.id,
name: board.name,
},
});
}, [board.id, board.name, openExportModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
@@ -108,9 +95,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
{t("setMobileHomeBoard.label")}
</Menu.Item>
<Menu.Item onClick={handleExportBoard} leftSection={<IconDownload {...iconProps} />}>
{t("export.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n() as unknown as (key: string) => string;
const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
@@ -10,7 +10,6 @@ import {
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDatabaseExport,
IconDirectionsFilled,
IconGitFork,
IconHelpSquareRoundedFilled,
@@ -155,12 +154,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
icon: IconDatabaseExport,
href: "/manage/tools/import-export",
hidden: !session?.user.permissions.includes("admin"),
},
],
},
{
@@ -1,58 +0,0 @@
"use client";
import { NumberInput, Switch, TextInput } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["docker"] }) => {
const tDocker = useScopedI18n("management.page.settings.section.docker" as never) as unknown as (
key: string,
) => string;
return (
<CommonSettingsForm settingKey="docker" defaultValues={defaultValues}>
{(form) => (
<>
<TextInput
{...form.getInputProps("targetBoardName")}
value={form.getInputProps("targetBoardName").value ?? ""}
label={tDocker("targetBoardName.label")}
description={tDocker("targetBoardName.description")}
/>
<Switch
{...form.getInputProps("readHomepageLabels", { type: "checkbox" })}
label={tDocker("readHomepageLabels.label")}
description={tDocker("readHomepageLabels.description")}
/>
<Switch
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
label={tDocker("createIntegrations.label")}
description={tDocker("createIntegrations.description")}
/>
<Switch
{...form.getInputProps("pruneRemoved", { type: "checkbox" })}
label={tDocker("pruneRemoved.label")}
description={tDocker("pruneRemoved.description")}
/>
<NumberInput
{...form.getInputProps("defaultItemWidth")}
label={tDocker("defaultItemWidth.label")}
description={tDocker("defaultItemWidth.description")}
min={1}
max={10}
/>
<NumberInput
{...form.getInputProps("defaultItemHeight")}
label={tDocker("defaultItemHeight.label")}
description={tDocker("defaultItemHeight.description")}
min={1}
max={10}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -5,15 +5,12 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { env } from "@homarr/docker/env";
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { DockerSettingsForm } from "./_components/docker-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
import { UserSettingsForm } from "./_components/user-settings-form";
@@ -62,12 +59,6 @@ export default async function SettingsPage() {
<Title order={2}>{tSettings("section.culture.title")}</Title>
<CultureSettingsForm defaultValues={serverSettings.culture} />
</Stack>
{env.ENABLE_DOCKER && (
<Stack>
<Title order={2}>{(tSettings as unknown as (key: string) => string)("section.docker.title")}</Title>
<DockerSettingsForm defaultValues={serverSettings.docker} />
</Stack>
)}
</Stack>
</>
);
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
@@ -44,34 +45,34 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
return () => undefined;
}
const fitAddon = new FitAddon();
const terminal = new Terminal({
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 14,
});
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
isTerminalReadyRef.current = true;
const terminal = terminalRef.current;
pendingLogsRef.current.forEach((data) => terminal.write(data));
pendingLogsRef.current = [];
terminal.refresh(0, terminal.rows - 1);
const fitTimeout = setTimeout(() => fitAddon.fit());
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(ref.current);
setTimeout(() => {
const fitAddon = new FitAddon();
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
clearTimeout(fitTimeout);
resizeObserver.disconnect();
isTerminalReadyRef.current = false;
pendingLogsRef.current = [];
fitAddon.dispose();
terminal.dispose();
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [writeToTerminal]);
@@ -1,124 +0,0 @@
"use client";
import { Avatar, Badge, Button, Card, Group, Stack, Table, Text, Title } from "@mantine/core";
import { IconRefresh, IconSearch } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export const DockerDiscoveryPanel = () => {
const tDiscovery = useScopedI18n("docker.discovery" as never) as unknown as (
key: string,
values?: Record<string, unknown>,
) => string;
const { data: preview, refetch, isLoading: isLoadingPreview, isFetched } =
clientApi.docker.getDiscoveredPreview.useQuery(undefined, {
enabled: false,
});
const { mutate: syncDiscovery, isPending: isSyncing } = clientApi.docker.syncDiscovery.useMutation({
onSuccess(result) {
showSuccessNotification({
title: tDiscovery("sync.notification.success.title"),
message: tDiscovery("sync.notification.success.message", {
created: result.created,
updated: result.updated,
}),
});
void refetch();
},
onError() {
showErrorNotification({
title: tDiscovery("sync.notification.error.title"),
message: tDiscovery("sync.notification.error.message"),
});
},
});
const services = preview ?? [];
return (
<Card withBorder>
<Stack>
<Group justify="space-between">
<Title order={3}>{tDiscovery("title")}</Title>
<Group gap="xs">
<Button
variant="light"
leftSection={<IconSearch size={16} />}
loading={isLoadingPreview}
onClick={() => refetch()}
>
{tDiscovery("preview.label")}
</Button>
<Button
leftSection={<IconRefresh size={16} />}
loading={isSyncing}
onClick={() => syncDiscovery()}
disabled={services.length === 0}
>
{tDiscovery("sync.label")}
</Button>
</Group>
</Group>
{!isFetched ? (
<Text c="dimmed" ta="center" py="xl">
{tDiscovery("preview.idle")}
</Text>
) : services.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{tDiscovery("preview.empty")}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{tDiscovery("field.name")}</Table.Th>
<Table.Th>{tDiscovery("field.group")}</Table.Th>
<Table.Th>{tDiscovery("field.href")}</Table.Th>
<Table.Th>{tDiscovery("field.host")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{services.map((service) => (
<Table.Tr key={`${service.host}/${service.containerId}`}>
<Table.Td>
<Group gap="xs">
{service.icon && (
<Avatar size="sm" radius="sm" src={service.icon}>
{service.name.charAt(0)}
</Avatar>
)}
<Text size="sm">{service.name}</Text>
{service.integrationKind && (
<Badge size="xs" variant="light">
{service.integrationKind}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{service.group}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={1}>
{service.href}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{service.host}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
);
};
@@ -1,138 +0,0 @@
"use client";
import { useState } from "react";
import { Alert, Button, Card, Group, List, Loader, Popover, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconDatabaseExport } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigSummaryList } from "~/components/config-import/config-summary-list";
const exportWarningPointKeys = [
"management.page.importExport.export.warning.message",
"management.page.importExport.export.warning.storage",
"management.page.importExport.export.warning.sharing",
] as const;
export const ExportConfigCard = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const { mutateAsync, isPending } = clientApi.config.exportFull.useMutation();
const [popoverOpened, setPopoverOpened] = useState(false);
const { data: preview, isLoading: previewLoading } = clientApi.config.previewExport.useQuery(undefined, {
enabled: popoverOpened,
});
const closePopover = () => setPopoverOpened(false);
const runExport = async () => {
await mutateAsync(undefined, {
onSuccess({ content, filename }) {
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
showSuccessNotification({
title: t("management.page.importExport.export.notification.success.title"),
message: t("management.page.importExport.export.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.export.notification.error.title"),
message: t("management.page.importExport.export.notification.error.message"),
});
},
});
};
const handleConfirmExport = () => {
closePopover();
void runExport();
};
return (
<Card withBorder>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.export.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.export.description")}
</Text>
</Stack>
<Popover
opened={popoverOpened}
onChange={setPopoverOpened}
width={420}
position="bottom-end"
withArrow
shadow="md"
>
<Popover.Target>
<Group justify="flex-end">
<Button
leftSection={<IconDatabaseExport size="1rem" />}
loading={isPending}
disabled={isPending}
onClick={() => setPopoverOpened(true)}
>
{t("management.page.importExport.export.action")}
</Button>
</Group>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="md">
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size="1.25rem" stroke={1.5} />}
title={t("management.page.importExport.export.warning.title")}
>
<List size="sm" spacing="xs">
{exportWarningPointKeys.map((key) => (
<List.Item key={key}>{t(key)}</List.Item>
))}
</List>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.summary.title")}
</Text>
{previewLoading && (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
{t("management.page.importExport.preview.exportLoading")}
</Text>
</Group>
)}
{preview && <ConfigSummaryList counts={preview} t={t} />}
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closePopover}>
{t("common.action.cancel")}
</Button>
<Button
color="red.9"
onClick={handleConfirmExport}
disabled={previewLoading}
loading={isPending}
>
{t("common.action.continue")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
</Stack>
</Card>
);
};
@@ -1,126 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, Button, Card, FileInput, Group, List, Stack, Text, Title } from "@mantine/core";
import { IconCheck, IconFileUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
export const ImportConfigCard = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const [file, setFile] = useState<File | null>(null);
const [fileContent, setFileContent] = useState("");
const [importResult, setImportResult] = useState<{
boards: number;
apps: number;
integrations: number;
groups: number;
searchEngines: number;
warnings: string[];
} | null>(null);
const {
mutate: runPreview,
data: preview,
isPending: previewLoading,
reset: resetPreview,
} = clientApi.config.previewImport.useMutation();
const { mutateAsync, isPending } = clientApi.config.importFull.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage");
},
});
useEffect(() => {
if (!fileContent) {
resetPreview();
return;
}
runPreview({ content: fileContent });
}, [fileContent, runPreview, resetPreview]);
const importAllowed = preview?.compatibility.status === "compatible";
const handleImport = async () => {
if (!file || !importAllowed) {
return;
}
const content = await file.text();
await mutateAsync(
{ content },
{
onSuccess(result) {
setImportResult(result);
showSuccessNotification({
title: t("management.page.importExport.importConfig.notification.success.title"),
message: t("management.page.importExport.importConfig.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.importConfig.notification.error.title"),
message: t("management.page.importExport.importConfig.notification.error.message"),
});
},
},
);
};
return (
<Card withBorder>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.importConfig.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.importConfig.description")}
</Text>
</Stack>
<FileInput
rightSection={<IconFileUpload />}
accept="application/json"
value={file}
onChange={(newFile) => {
setFile(newFile);
setImportResult(null);
setFileContent("");
if (!newFile) {
return;
}
void newFile.text().then((content) => {
setFileContent(content);
});
}}
label={t("management.page.importExport.importConfig.fileLabel")}
/>
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
{importResult && (
<Alert color="green" icon={<IconCheck />}>
<List size="sm">
<List.Item>{t("management.page.importExport.importConfig.result.boardsImported", { count: importResult.boards })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.appsCreated", { count: importResult.apps })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.integrationsCreated", { count: importResult.integrations })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.groupsCreated", { count: importResult.groups })}</List.Item>
</List>
</Alert>
)}
<Group justify="end">
<Button loading={isPending} disabled={!file || !importAllowed || previewLoading} onClick={() => void handleImport()}>
{t("common.action.import")}
</Button>
</Group>
</Stack>
</Card>
);
};
@@ -1,181 +0,0 @@
"use client";
import { useState } from "react";
import { Button, Card, Code, FileInput, Group, Stack, Switch, Text, TextInput, Title } from "@mantine/core";
import { IconFileUpload } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import {
extractHomepageEnvVariables,
parseServicesYaml,
replaceHomepageEnvVariables,
} from "@homarr/board-portability/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { boardNameSchema } from "@homarr/validation/board";
export const ImportHomepageCard = () => {
const t = useI18n() as unknown as (key: string) => string;
const [fileValid, setFileValid] = useState(true);
const [rawContent, setRawContent] = useState("");
const [envVars, setEnvVars] = useState<string[]>([]);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const form = useZodForm(
z.object({
file: z.file(),
boardName: boardNameSchema,
createIntegrations: z.boolean(),
}),
{
mode: "controlled",
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
boardName: "",
createIntegrations: true,
},
onValuesChange(values, previous) {
void (async () => {
if (values.file === previous.file) return;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!values.file) return;
const content = await values.file.text();
setRawContent(content);
const vars = extractHomepageEnvVariables(content);
setEnvVars(vars);
setEnvValues(Object.fromEntries(vars.map((v: string) => [v, ""])));
const result = parseServicesYaml(content);
if (!result.success) {
setFileValid(false);
return;
}
setFileValid(true);
const suggestedName =
result.services[0]?.group.replaceAll(/[^A-Za-z0-9-_]/g, "-").replaceAll(/-+/g, "-") ?? "homepage";
if (form.values.boardName.trim().length === 0) {
form.setFieldValue("boardName", suggestedName.slice(0, 255));
}
})();
},
},
);
const { mutateAsync, isPending } = clientApi.board.importHomepageServices.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/boards");
},
});
const allEnvVarsFilled = envVars.length === 0 || envVars.every((v) => (envValues[v] ?? "").trim().length > 0);
return (
<Card withBorder>
<form
onSubmit={form.onSubmit((values) => {
if (!fileValid || !allEnvVarsFilled) return;
void (async () => {
const resolvedContent =
envVars.length > 0 ? replaceHomepageEnvVariables(rawContent, envValues) : rawContent;
await mutateAsync(
{
content: resolvedContent,
boardName: values.boardName,
createIntegrations: values.createIntegrations,
},
{
onSuccess() {
showSuccessNotification({
title: t("management.page.importExport.importHomepage.notification.success.title"),
message: t("management.page.importExport.importHomepage.notification.success.message"),
});
form.reset();
setRawContent("");
setEnvVars([]);
setEnvValues({});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.importHomepage.notification.error.title"),
message: t("management.page.importExport.importHomepage.notification.error.message"),
});
},
},
);
})();
})}
>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.importHomepage.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.importHomepage.description")}
</Text>
</Stack>
<FileInput
rightSection={<IconFileUpload />}
withAsterisk
accept=".yaml,.yml"
{...form.getInputProps("file")}
error={
(form.getInputProps("file").error as string | undefined) ??
(!fileValid && form.isDirty("file") ? t("management.page.importExport.importHomepage.invalidFile") : undefined)
}
type="button"
label={t("management.page.importExport.importHomepage.fileLabel")}
/>
{envVars.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.importHomepage.envVariables.label")}
</Text>
<Text size="xs" c="dimmed">
{t("management.page.importExport.importHomepage.envVariables.description")}
</Text>
{envVars.map((varName) => (
<TextInput
key={varName}
label={<Code>{`{{${varName}}}`}</Code>}
placeholder={varName.replace("HOMEPAGE_VAR_", "").toLowerCase().replaceAll("_", ".")}
value={envValues[varName] ?? ""}
onChange={(event) => {
setEnvValues((prev) => ({ ...prev, [varName]: event.target.value }));
}}
withAsterisk
/>
))}
</Stack>
)}
<TextInput
withAsterisk
label={t("management.page.importExport.importHomepage.boardNameLabel")}
{...form.getInputProps("boardName")}
/>
<Switch
label={t("management.page.importExport.importHomepage.createIntegrations")}
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending} disabled={!allEnvVarsFilled}>
{t("common.action.import")}
</Button>
</Group>
</Stack>
</form>
</Card>
);
};
@@ -1,34 +0,0 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { DockerDiscoveryPanel } from "./docker-discovery-panel";
import { ExportConfigCard } from "./export-config-card";
import { ImportConfigCard } from "./import-config-card";
import { ImportHomepageCard } from "./import-homepage-card";
export default async function ImportExportPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const t = await getScopedI18n("management.page.importExport" as never) as unknown as (key: string) => string;
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{t("title")}</Title>
<ExportConfigCard />
<ImportConfigCard />
<ImportHomepageCard />
{env.ENABLE_DOCKER && <DockerDiscoveryPanel />}
</Stack>
</>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
@@ -14,8 +15,8 @@ export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels, fontSize } = useLogContext();
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
clientApi.log.subscribe.useSubscription(
{
@@ -37,31 +38,28 @@ export const TerminalComponent = () => {
return () => undefined;
}
const fitAddon = new FitAddon();
const terminal = new Terminal({
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize,
});
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
const fitTerminal = () => fitAddon.fit();
const fitTimeout = setTimeout(fitTerminal);
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(ref.current);
setTimeout(() => {
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
clearTimeout(fitTimeout);
resizeObserver.disconnect();
fitAddon.dispose();
terminal.dispose();
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
};
}, []);
@@ -0,0 +1,155 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -0,0 +1,116 @@
.wrapper {
position: absolute;
left: 10px;
right: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
z-index: 200;
pointer-events: auto;
opacity: 0;
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
.panel {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
}
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
backdrop-filter: blur(8px);
overflow: hidden;
}
.nameSection {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 0;
}
.widgetIcon {
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.integrations {
flex-shrink: 0;
}
.integrationAvatar {
border: none;
}
.metadata {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-left: auto;
}
.metadataItem {
@mixin dark {
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
}
@mixin light {
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
}
display: flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.metadataLabel {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.metadataValue {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.wrapper {
display: none;
}
}
@@ -0,0 +1,86 @@
"use client";
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Translator } from "./widget-hover-metadata";
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
import classes from "./widget-hover-overlay.module.css";
interface WidgetHoverOverlayProps {
item: SectionItem;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode();
const settings = useSettings();
const t = useI18n();
const { definition } = widgetImports[item.kind];
const WidgetIcon = definition.icon;
const translate = ((key: string) => t(key as never)) as Translator;
const displayName = resolveWidgetDisplayName(item, settings, translate);
const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id),
);
const metadataItems = collectWidgetHoverMetadata(
item,
settings,
connectedIntegrations.map((integration) => integration.kind),
translate,
);
const hasIntegrations = connectedIntegrations.length > 0;
const hasMetadata = metadataItems.length > 0;
if (isEditMode) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
<Text className={classes.name} title={displayName}>
{displayName}
</Text>
</div>
{hasIntegrations && (
<Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar
src={getIconUrl(integration.kind)}
size={20}
radius="sm"
className={classes.integrationAvatar}
/>
</Tooltip>
))}
</Avatar.Group>
)}
{hasMetadata && (
<Group className={classes.metadata} gap={4}>
{metadataItems.map((metadataItem) => (
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
<span className={classes.metadataLabel}>{metadataItem.label}</span>
<span className={classes.metadataValue}>{metadataItem.value}</span>
</div>
))}
</Group>
)}
</div>
</div>
);
};
@@ -2,8 +2,12 @@ import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
@@ -11,6 +15,7 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -18,7 +23,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].sort((itemA, itemB) => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset;
}
@@ -30,7 +35,7 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
))}
</>
);
@@ -39,12 +44,12 @@ export const SectionContent = () => {
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
const Item = ({ item, innerRef }: ItemProps) => {
const Item = ({ item, innerRef, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<GridStackItem
key={item.id}
@@ -59,6 +64,7 @@ const Item = ({ item, innerRef }: ItemProps) => {
minWidth={minWidth}
minHeight={minHeight}
>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
@@ -1,138 +0,0 @@
"use client";
import { Alert, Group, List, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { ConfigImportPreview } from "@homarr/board-portability";
import { ConfigSummaryList } from "./config-summary-list";
const compatibilityAlertColor: Record<ConfigImportPreview["compatibility"]["status"], string> = {
compatible: "blue",
unsupportedVersion: "red",
invalidStructure: "red",
};
type CountSection = { labelKey: string; entries: { key: string; count: number }[] };
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const buildCountSections = (preview: ConfigImportPreview): CountSection[] => [
{
labelKey: "management.page.importExport.preview.importWillReuse",
entries: Object.entries(preview.toReuse)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.reuse${capitalize(key)}`, count })),
},
{
labelKey: "management.page.importExport.preview.importWillSkip",
entries: Object.entries(preview.toSkip)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.skip${capitalize(key)}`, count })),
},
{
labelKey: "management.page.importExport.preview.importWillUpdate",
entries: Object.entries(preview.toUpdate)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.update${capitalize(key)}`, count })),
},
];
type ConfigImportPreviewPanelProps = {
preview: ConfigImportPreview | undefined;
previewLoading: boolean;
t: (key: string, values?: Record<string, unknown>) => string;
};
export const ConfigImportPreviewPanel = ({ preview, previewLoading, t }: ConfigImportPreviewPanelProps) => {
if (!preview && !previewLoading) {
return null;
}
const importAllowed = preview?.compatibility.status === "compatible";
return (
<Stack gap="sm">
{previewLoading && (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
{t("management.page.importExport.preview.importLoading")}
</Text>
</Group>
)}
{preview && (
<>
<Alert
variant="light"
color={compatibilityAlertColor[preview.compatibility.status]}
icon={<IconAlertTriangle size="1rem" stroke={1.5} />}
title={
preview.compatibility.status === "compatible"
? t("management.page.importExport.preview.migrationNote", {
bundleVersion: preview.meta.bundleVersion || "?",
})
: t("management.page.importExport.preview.importIncompatible")
}
>
{preview.compatibility.status === "compatible" && preview.meta.exportedAt && (
<Text size="sm" mb="xs">
{t("management.page.importExport.preview.importMeta", {
exportedAt: preview.meta.exportedAt,
homarrVersion: preview.meta.homarrVersion,
bundleVersion: preview.meta.bundleVersion,
})}
</Text>
)}
{preview.compatibility.issues.length > 0 && (
<List size="sm" spacing={4}>
{preview.compatibility.issues.map((issue) => (
<List.Item key={issue}>{issue}</List.Item>
))}
</List>
)}
</Alert>
{importAllowed && (
<>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillCreate")}
</Text>
<ConfigSummaryList counts={preview.toCreate} t={t} />
</Stack>
{buildCountSections(preview)
.filter((section) => section.entries.length > 0)
.map((section) => (
<Stack gap="xs" key={section.labelKey}>
<Text size="sm" fw={500}>
{t(section.labelKey)}
</Text>
<List size="sm">
{section.entries.map((entry) => (
<List.Item key={entry.key}>
{t(entry.key, { count: entry.count })}
</List.Item>
))}
</List>
</Stack>
))}
{preview.warnings.length > 0 && (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="1rem" />}>
<List size="sm">
{preview.warnings.map((warning) => (
<List.Item key={warning}>{warning}</List.Item>
))}
</List>
</Alert>
)}
</>
)}
</>
)}
</Stack>
);
};
@@ -1,35 +0,0 @@
"use client";
import { List, Text } from "@mantine/core";
import type { ConfigEntityCounts, ConfigEntityKey } from "@homarr/board-portability";
import { CONFIG_ENTITY_KEYS } from "@homarr/board-portability";
const TRANSLATION_PREFIX = "management.page.importExport.summary";
type ConfigSummaryListProps = {
counts: ConfigEntityCounts;
t: (key: string, values?: Record<string, unknown>) => string;
};
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
const entries = CONFIG_ENTITY_KEYS.filter((key) => counts[key] > 0);
if (entries.length === 0) {
return (
<Text size="sm" c="dimmed">
{t(`${TRANSLATION_PREFIX}.empty`)}
</Text>
);
}
return (
<List size="sm" spacing={4}>
{entries.map((key: ConfigEntityKey) => (
<List.Item key={key}>
{t(`${TRANSLATION_PREFIX}.${key}`, { count: counts[key] })}
</List.Item>
))}
</List>
);
};
+4
View File
@@ -102,6 +102,10 @@
overflow-y: auto;
}
.grid-stack > .grid-stack-item[data-type="item"] {
overflow: visible;
}
.grid-stack.grid-stack-animate {
transition: none;
}
-153
View File
@@ -1,153 +0,0 @@
# Homarr Docker label discovery example.
# This compose file demonstrates all supported label combinations for testing.
# Prerequisites: Homarr with Docker socket mounted.
# In Manage → Tools → Import/Export, use the Docker Discovery panel to scan.
services:
# ─── Full Homarr labels (all fields) ─────────────────────────────────────────
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: homarr-sonarr
ports:
- "8989:8989"
restart: unless-stopped
labels:
homarr.name: "Sonarr"
homarr.group: "Media"
homarr.href: "http://sonarr:8989"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
homarr.description: "TV series management"
homarr.ping: "http://sonarr:8989/ping"
homarr.id: "sonarr-main"
homarr.board: "homelab"
homarr.integration: "sonarr"
homarr.widget: "downloads"
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: homarr-radarr
ports:
- "7878:7878"
restart: unless-stopped
labels:
homarr.name: "Radarr"
homarr.group: "Media"
homarr.href: "http://radarr:7878"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg"
homarr.description: "Movie management"
homarr.ping: "http://radarr:7878/ping"
homarr.integration: "radarr"
homarr.widget: "downloads"
# ─── Minimal Homarr labels (required fields only) ────────────────────────────
portainer:
image: portainer/portainer-ce:latest
container_name: homarr-portainer
ports:
- "9000:9000"
restart: unless-stopped
labels:
homarr.name: "Portainer"
homarr.group: "Infrastructure"
homarr.href: "http://portainer:9000"
# ─── Homepage labels only (migration scenario) ───────────────────────────────
grafana:
image: grafana/grafana:latest
container_name: homarr-grafana
ports:
- "3000:3000"
restart: unless-stopped
labels:
homepage.name: "Grafana"
homepage.group: "Monitoring"
homepage.href: "http://grafana:3000"
homepage.icon: "grafana"
homepage.description: "Metrics and dashboards"
prometheus:
image: prom/prometheus:latest
container_name: homarr-prometheus
ports:
- "9090:9090"
restart: unless-stopped
labels:
homepage.name: "Prometheus"
homepage.group: "Monitoring"
homepage.href: "http://prometheus:9090"
homepage.icon: "prometheus"
# ─── Both label sets (homarr takes priority) ─────────────────────────────────
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: homarr-qbittorrent
ports:
- "8080:8080"
restart: unless-stopped
labels:
homarr.name: "qBittorrent"
homarr.group: "Downloads"
homarr.href: "http://qbittorrent:8080"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg"
homarr.description: "Torrent client"
homarr.ping: "http://qbittorrent:8080"
homarr.integration: "qBittorrent"
homarr.widget: "downloads"
homepage.name: "qBittorrent (homepage)"
homepage.group: "Downloads (homepage)"
homepage.href: "http://qbittorrent:8080/homepage"
homepage.icon: "qbittorrent"
homepage.description: "Should be ignored"
# ─── Hidden container (excluded from discovery) ──────────────────────────────
watchtower:
image: containrrr/watchtower:latest
container_name: homarr-watchtower
restart: unless-stopped
labels:
homarr.hide: "true"
homarr.name: "Watchtower"
homarr.group: "Infrastructure"
homarr.href: "http://watchtower:8080"
# ─── Targeting a specific board ──────────────────────────────────────────────
traefik:
image: traefik:latest
container_name: homarr-traefik
ports:
- "80:80"
- "8081:8080"
restart: unless-stopped
labels:
homarr.name: "Traefik"
homarr.group: "Networking"
homarr.href: "http://traefik:8080"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg"
homarr.board: "networking"
homarr.ping: "http://traefik:8080/ping"
# ─── Custom external ID ──────────────────────────────────────────────────────
jellyfin:
image: jellyfin/jellyfin:latest
container_name: homarr-jellyfin
ports:
- "8096:8096"
restart: unless-stopped
labels:
homarr.name: "Jellyfin"
homarr.group: "Media"
homarr.href: "http://jellyfin:8096"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg"
homarr.description: "Media streaming"
homarr.ping: "http://jellyfin:8096/health"
homarr.id: "jellyfin-production"
homarr.integration: "jellyfin"
homarr.widget: "mediaServer"
# ─── No discovery labels (should be completely ignored) ──────────────────────
redis:
image: redis:alpine
container_name: homarr-redis
ports:
- "6379:6379"
restart: unless-stopped
+4 -4
View File
@@ -78,7 +78,7 @@
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"axios@>=1.0.0 <1.8.2": ">=1.17.0",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0",
@@ -88,13 +88,13 @@
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.6",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
"react-is": "^19.2.7",
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.7",
"vite@>=5.0.0 <=5.4.18": ">=8.0.15"
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
-1
View File
@@ -22,7 +22,6 @@
},
"dependencies": {
"@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
-1
View File
@@ -28,7 +28,6 @@ export const appRouter = createTRPCRouter({
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
config: lazy(() => import("./router/config").then((mod) => mod.configRouter)),
});
// export type definition of API
-117
View File
@@ -35,12 +35,6 @@ import {
getPermissionsWithParents,
widgetKinds,
} from "@homarr/definitions";
import {
exportBoardBundleAsync,
importBoardBundleAsync,
importHomepageServicesAsync,
parseBundleJson,
} from "@homarr/board-portability";
import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
@@ -49,9 +43,6 @@ import {
boardChangeVisibilitySchema,
boardCreateSchema,
boardDuplicateSchema,
boardExportBundleSchema,
boardImportBundleSchema,
boardNameSchema,
boardRenameSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
@@ -63,7 +54,6 @@ import { zodUnionFromArray } from "@homarr/validation/enums";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import packageJson from "../../../../package.json";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
@@ -1337,113 +1327,6 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
exportBundle: protectedProcedure.input(boardExportBundleSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
const result = await exportBoardBundleAsync(ctx.db, input.id, packageJson.version);
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
}
return {
filename: result.filename,
content: result.content,
};
}),
importBundle: permissionRequiredProcedure
.requiresPermission("board-create")
.input(boardImportBundleSchema)
.mutation(async ({ ctx, input }) => {
await noBoardWithSimilarNameAsync(ctx.db, input.name);
const bundle = parseBundleJson(input.content);
const existingApps = await ctx.db.query.apps.findMany();
const existingIntegrations = await ctx.db.query.integrations.findMany();
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
const integrationIdsWithAccess = hasAccessForAll
? []
: await ctx.db
.selectDistinct({
id: integrationGroupPermissions.integrationId,
})
.from(integrationGroupPermissions)
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, ctx.session.user.id))
.union(
ctx.db
.selectDistinct({ id: integrationUserPermissions.integrationId })
.from(integrationUserPermissions)
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
)
.then((result) => result.map((row) => row.id));
const report = await importBoardBundleAsync(
ctx.db,
bundle,
input.name,
ctx.session.user.id,
existingApps,
existingIntegrations,
integrationIdsWithAccess,
hasAccessForAll,
);
return {
boardId: report.boardId,
created: report.created,
warnings: report.warnings,
};
}),
importHomepageServices: permissionRequiredProcedure
.requiresPermission("board-create")
.input(
z.object({
content: z.string().min(1),
boardName: boardNameSchema,
createIntegrations: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) => {
await noBoardWithSimilarNameAsync(ctx.db, input.boardName);
try {
const report = await importHomepageServicesAsync(
ctx.db,
input.content,
input.boardName,
ctx.session.user.id,
input.createIntegrations,
);
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
homeBoardId: true,
},
});
if (!user?.homeBoardId) {
await ctx.db.update(users).set({ homeBoardId: report.boardId }).where(eq(users.id, ctx.session.user.id));
}
return {
boardId: report.boardId,
created: report.created,
warnings: report.warnings,
unmappedWidgetTypes: report.unmappedWidgetTypes,
};
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Could not import Homepage services",
});
}
}),
});
/**
-42
View File
@@ -1,42 +0,0 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import {
exportFullConfigAsync,
importFullConfigAsync,
parseAndValidateBundle,
previewExportFullConfigAsync,
previewImportFullConfigAsync,
} from "@homarr/board-portability";
import packageJson from "../../../../package.json";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const configRouter = createTRPCRouter({
previewExport: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await previewExportFullConfigAsync(ctx.db);
}),
previewImport: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
}),
exportFull: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
return await exportFullConfigAsync(ctx.db, packageJson.version);
}),
importFull: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
try {
const bundle = parseAndValidateBundle(input.content, packageJson.version);
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
}
}),
});
@@ -3,8 +3,7 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { DockerSingleton } from "@homarr/docker";
import {
dockerContainersRequestHandler,
getContainerLogsAsync,
@@ -109,32 +108,6 @@ export const dockerRouter = createTRPCRouter({
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
getDiscoveredPreview: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
return await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
}),
syncDiscovery: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.mutation(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
const discovered = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
return await syncDiscoveredServicesAsync(discovered, {
targetBoardName: serverSettings.docker.targetBoardName,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
defaultItemWidth: serverSettings.docker.defaultItemWidth,
defaultItemHeight: serverSettings.docker.defaultItemHeight,
});
}),
logs: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
@@ -1,8 +1,5 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { importFullConfigAsync, parseAndValidateBundle, previewImportFullConfigAsync } from "@homarr/board-portability";
import { onboarding } from "@homarr/db/schema";
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
import {
ensureValidTokenOrThrow,
@@ -10,7 +7,6 @@ import {
importInitialOldmarrInputSchema,
} from "@homarr/old-import/import";
import packageJson from "../../../../../package.json";
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
@@ -23,7 +19,12 @@ export const importRouter = createTRPCRouter({
}),
validateToken: onboardingProcedure
.requiresStep("import")
.input(z.object({ checksum: z.string(), token: z.string() }))
.input(
z.object({
checksum: z.string(),
token: z.string(),
}),
)
.mutation(({ input }) => {
try {
ensureValidTokenOrThrow(input.checksum, input.token);
@@ -39,28 +40,4 @@ export const importRouter = createTRPCRouter({
await importInitialOldmarrAsync(ctx.db, input);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
previewInitialConfigImport: onboardingProcedure
.requiresStep("import")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
}),
importInitialConfigImport: onboardingProcedure
.requiresStep("import")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
try {
const bundle = parseAndValidateBundle(input.content, packageJson.version);
await importFullConfigAsync(ctx.db, bundle, null);
const hasUsers = (bundle.users ?? []).length > 0;
if (hasUsers) {
await ctx.db.update(onboarding).set({ previousStep: "import", step: "finish" });
} else {
await nextOnboardingStepAsync(ctx.db, undefined);
}
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
}
}),
});
@@ -50,19 +50,6 @@ vi.mock("@homarr/docker/env", () => ({
ENABLE_DOCKER: true,
},
}));
vi.mock("@homarr/docker", () => ({
DockerSingleton: {
getInstances: () => [],
},
listDiscoveredContainersAsync: async () => [],
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
}));
vi.mock("@homarr/db/queries", () => ({
getServerSettingsAsync: async () => ({
docker: { targetBoardName: null, readHomepageLabels: true, pruneRemoved: false },
board: { enableStatusByDefault: true, forceDisableStatus: false },
}),
}));
const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) =>
({
@@ -86,8 +73,6 @@ const validInputs: {
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
getDiscoveredPreview: undefined,
syncDiscovery: undefined,
logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 },
};
+14 -36
View File
@@ -52,45 +52,23 @@ export const userRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const existingUser = await ctx.db.query.users.findFirst({
where: eq(users.email, input.email),
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const userId = await createUserAsync(ctx.db, input);
const groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: credentialsAdminGroup,
ownerId: userId,
position: maxPosition + 1,
});
const userId = existingUser ? existingUser.id : await createUserAsync(ctx.db, input);
const existingGroup = await ctx.db.query.groups.findFirst({
where: eq(groups.name, credentialsAdminGroup),
await ctx.db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
let groupId: string;
if (existingGroup) {
groupId = existingGroup.id;
} else {
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: credentialsAdminGroup,
ownerId: userId,
position: maxPosition + 1,
});
await ctx.db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
}
const existingMembership = await ctx.db.query.groupMembers.findFirst({
where: and(eq(groupMembers.groupId, groupId), eq(groupMembers.userId, userId)),
await ctx.db.insert(groupMembers).values({
groupId,
userId,
});
if (!existingMembership) {
await ctx.db.insert(groupMembers).values({
groupId,
userId,
});
}
await nextOnboardingStepAsync(ctx.db, undefined);
}),
register: publicProcedure
-9
View File
@@ -1,9 +0,0 @@
export {
homarrBundleSchema,
homarrConfigBundleSchema,
type HomarrBundle,
type HomarrConfigBundle,
} from "./src/schema";
export { parseServicesYaml } from "./src/homepage/parse-services-yaml";
export { extractHomepageEnvVariables, replaceHomepageEnvVariables } from "./src/homepage/env-variables";
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./src/homepage/types";
-25
View File
@@ -1,25 +0,0 @@
export {
CONFIG_BUNDLE_FORMAT_VERSION,
assessBundleCompatibility,
parseAndValidateBundle,
parseConfigBundleJson,
} from "./src/config-bundle-compat";
export { USER_DIRECT_FIELDS, USER_REF_FIELDS, GROUP_REF_FIELDS } from "./src/entity-fields";
export { resolveEntities, groupByKey, type EntityResolverDef, type ResolveResult } from "./src/resolve-entities";
export { homarrBundleSchema, homarrConfigBundleSchema, type HomarrBundle, type HomarrConfigBundle, type ConfigBundleUser } from "./src/schema";
export type { ConfigEntityCounts, ConfigEntityKey, ConfigExportPreview, ConfigImportPreview } from "./src/types";
export { CONFIG_ENTITY_KEYS, emptyCounts } from "./src/types";
export { exportBoardBundleAsync, bundleFilenameForBoard } from "./src/export";
export { exportFullConfigAsync } from "./src/export/export-full-config";
export { previewExportFullConfigAsync } from "./src/export/preview-export-full-config";
export { parseBundleJson, importBoardBundleAsync, type BundleImportReport } from "./src/import";
export { importFullConfigAsync, type ConfigImportReport } from "./src/import/import-full-config";
export { previewImportFullConfigAsync } from "./src/import/plan-full-config-import";
export {
parseServicesYaml,
importHomepageServicesAsync,
homepageWidgetMap,
type HomepageService,
type HomepageImportReport,
type ParseServicesYamlResult,
} from "./src/homepage";
-40
View File
@@ -1,40 +0,0 @@
{
"name": "@homarr/board-portability",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./index.ts",
"./client": "./client.ts",
"./homepage": "./src/homepage/index.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"superjson": "catalog:",
"yaml": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -1,178 +0,0 @@
import { describe, expect, test } from "vitest";
import type { HomarrBundle } from "../schema";
import { homarrBundleSchema } from "../schema";
import { bundleToJson, bundleFilenameForBoard } from "../export/to-json";
import { parseBundleJson } from "../import/parse-bundle";
const createMinimalBundle = (overrides: Partial<HomarrBundle> = {}): HomarrBundle => ({
version: "1.0",
exportedAt: new Date().toISOString(),
homarrVersion: "1.0.0-test",
boards: [
{
name: "Test Board",
settings: {
primaryColor: "#FA5252",
secondaryColor: "#868E96",
opacity: 100,
itemRadius: "md",
},
layouts: [
{
ref: "layout-1",
name: "Default",
columnCount: 10,
breakpoint: 0,
},
],
sections: [
{
ref: "section-1",
kind: "category",
name: "Apps",
yOffset: 0,
xOffset: 0,
},
],
items: [
{
ref: "item-1",
kind: "app",
options: { appId: "app-ref-1", openInNewTab: true },
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
integrationRefs: [],
appRef: "app-ref-1",
layouts: [
{
layoutRef: "layout-1",
sectionRef: "section-1",
x: 0,
y: 0,
w: 2,
h: 1,
},
],
},
],
},
],
apps: [
{
ref: "app-ref-1",
name: "Jellyfin",
href: "https://jellyfin.local",
iconUrl: "https://cdn.example/jellyfin.png",
description: "Media server",
pingUrl: "https://jellyfin.local/health",
},
],
integrations: [
{
ref: "int-ref-1",
kind: "jellyfin",
name: "Jellyfin",
url: "https://jellyfin.local",
secretKinds: ["apiKey"],
secrets: "REDACTED",
},
],
...overrides,
});
describe("bundle round-trip", () => {
test("serialized bundle parses back to equal object", () => {
const original = createMinimalBundle();
const json = bundleToJson(original);
const parsed = parseBundleJson(json);
expect(parsed.version).toBe(original.version);
expect(parsed.homarrVersion).toBe(original.homarrVersion);
expect(parsed.boards).toHaveLength(1);
expect(parsed.boards[0]?.name).toBe("Test Board");
expect(parsed.apps).toHaveLength(1);
expect(parsed.apps[0]?.name).toBe("Jellyfin");
expect(parsed.integrations).toHaveLength(1);
expect(parsed.integrations[0]?.secrets).toBe("REDACTED");
});
test("schema validates valid bundle", () => {
const bundle = createMinimalBundle();
const result = homarrBundleSchema.safeParse(bundle);
expect(result.success).toBe(true);
});
test("schema rejects missing boards", () => {
const invalid = { ...createMinimalBundle(), boards: [] };
const result = homarrBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("schema rejects invalid version", () => {
const invalid = { ...createMinimalBundle(), version: "2.0" };
const result = homarrBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("bundle preserves multiple items with layouts", () => {
const bundle = createMinimalBundle();
bundle.boards[0]?.items.push({
ref: "item-2",
kind: "clock",
options: { is24HourFormat: true },
advancedOptions: { title: "My Clock", customCssClasses: ["custom"], borderColor: "#000" },
integrationRefs: [],
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 2, y: 0, w: 2, h: 1 }],
});
const json = bundleToJson(bundle);
const parsed = parseBundleJson(json);
expect(parsed.boards[0]?.items).toHaveLength(2);
expect(parsed.boards[0]?.items[1]?.kind).toBe("clock");
expect(parsed.boards[0]?.items[1]?.advancedOptions.title).toBe("My Clock");
});
test("bundle preserves section layouts", () => {
const bundle = createMinimalBundle();
const firstSection = bundle.boards[0]?.sections[0];
if (!firstSection) throw new Error("missing section");
firstSection.layouts = [
{
layoutRef: "layout-1",
parentSectionRef: "section-1",
xOffset: 0,
yOffset: 0,
width: 10,
height: 5,
},
];
const json = bundleToJson(bundle);
const parsed = parseBundleJson(json);
expect(parsed.boards[0]?.sections[0]?.layouts).toHaveLength(1);
expect(parsed.boards[0]?.sections[0]?.layouts?.[0]?.width).toBe(10);
});
test("empty apps and integrations are valid", () => {
const bundle = createMinimalBundle({ apps: [], integrations: [] });
if (bundle.boards[0]) {
bundle.boards[0].items = [];
}
const json = bundleToJson(bundle);
const parsed = parseBundleJson(json);
expect(parsed.apps).toHaveLength(0);
expect(parsed.integrations).toHaveLength(0);
});
});
describe("bundleFilenameForBoard", () => {
test("sanitizes board name for filename", () => {
expect(bundleFilenameForBoard("My Board")).toBe("homarr-board-My-Board.json");
expect(bundleFilenameForBoard("test_board-1")).toBe("homarr-board-test_board-1.json");
expect(bundleFilenameForBoard("special!@#chars")).toBe("homarr-board-special---chars.json");
});
});
@@ -1,237 +0,0 @@
import { describe, expect, test } from "vitest";
import type { HomarrConfigBundle } from "../schema";
import { homarrConfigBundleSchema } from "../schema";
import { assessBundleCompatibility, parseConfigBundleJson } from "../config-bundle-compat";
const createMinimalConfigBundle = (overrides: Partial<HomarrConfigBundle> = {}): HomarrConfigBundle => ({
version: "2.0",
type: "full-config",
exportedAt: new Date().toISOString(),
homarrVersion: "1.5.0",
encryptionKey: "a".repeat(64),
boards: [
{
ref: "board-uuid-1",
name: "Main Dashboard",
settings: {
primaryColor: "#FA5252",
secondaryColor: "#868E96",
opacity: 100,
itemRadius: "md",
},
layouts: [
{ ref: "layout-1", name: "Default", columnCount: 10, breakpoint: 0 },
],
sections: [
{ ref: "section-1", kind: "category", name: "Media", yOffset: 0, xOffset: 0 },
],
items: [
{
ref: "item-1",
kind: "app",
options: { appId: "app-ref-1", openInNewTab: true },
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
integrationRefs: ["int-ref-1"],
appRef: "app-ref-1",
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 0, y: 0, w: 2, h: 1 }],
},
],
},
],
apps: [
{
ref: "app-ref-1",
name: "Jellyfin",
href: "https://jellyfin.local",
iconUrl: "https://cdn.example/jellyfin.png",
description: "Media server",
pingUrl: null,
},
],
integrations: [
{
ref: "int-ref-1",
kind: "jellyfin",
name: "Jellyfin",
url: "https://jellyfin.local",
secrets: [{ kind: "apiKey", value: "encrypted.value" }],
},
],
serverSettings: {
board: { enableStatusByDefault: true, forceDisableStatus: false },
docker: { targetBoardName: null, readHomepageLabels: true },
},
searchEngines: [
{
ref: "se-1",
iconUrl: "https://google.com/favicon.ico",
name: "Google",
short: "g",
description: "Google search",
urlTemplate: "https://google.com/search?q={query}",
type: "generic",
integrationRef: null,
},
],
groups: [
{
ref: "group-1",
name: "Admins",
position: 0,
ownerRef: "user-1",
homeBoardRef: null,
mobileHomeBoardRef: null,
permissions: ["admin"],
boardPermissions: [{ boardRef: "board-uuid-1", permission: "board-full-access" }],
integrationPermissions: [{ integrationRef: "int-ref-1", permission: "integration-use-credentials" }],
memberRefs: ["user-1"],
},
],
users: [
{
ref: "user-1",
name: "admin",
email: "admin@example.com",
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
provider: "credentials",
homeBoardRef: "board-uuid-1",
mobileHomeBoardRef: null,
defaultSearchEngineRef: "se-1",
colorScheme: "dark",
firstDayOfWeek: 1,
openSearchInNewTab: true,
ddgBangs: true,
pingIconsEnabled: false,
},
],
...overrides,
});
describe("v2.0 config bundle schema", () => {
test("schema validates a valid config bundle", () => {
const bundle = createMinimalConfigBundle();
const result = homarrConfigBundleSchema.safeParse(bundle);
expect(result.success).toBe(true);
});
test("schema rejects v1.0 version for config bundle", () => {
const invalid = { ...createMinimalConfigBundle(), version: "1.0" };
const result = homarrConfigBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("schema rejects missing type field", () => {
const { type: _type, ...invalid } = createMinimalConfigBundle();
const result = homarrConfigBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("schema accepts bundle with no boards", () => {
const bundle = createMinimalConfigBundle({ boards: [] });
const result = homarrConfigBundleSchema.safeParse(bundle);
expect(result.success).toBe(true);
});
test("schema accepts board with optional ref field", () => {
const bundle = createMinimalConfigBundle();
delete (bundle.boards[0] as Record<string, unknown>).ref;
const result = homarrConfigBundleSchema.safeParse(bundle);
expect(result.success).toBe(true);
});
});
describe("v2.0 config bundle round-trip", () => {
test("serialized config bundle parses back correctly", () => {
const original = createMinimalConfigBundle();
const json = JSON.stringify(original);
const parsed = parseConfigBundleJson(json);
expect(parsed.version).toBe("2.0");
expect(parsed.type).toBe("full-config");
expect(parsed.homarrVersion).toBe("1.5.0");
expect(parsed.boards).toHaveLength(1);
expect(parsed.boards[0]?.ref).toBe("board-uuid-1");
expect(parsed.boards[0]?.name).toBe("Main Dashboard");
expect(parsed.apps).toHaveLength(1);
expect(parsed.integrations).toHaveLength(1);
expect(parsed.integrations[0]?.secrets).toHaveLength(1);
expect(parsed.searchEngines).toHaveLength(1);
expect(parsed.groups).toHaveLength(1);
expect(parsed.groups[0]?.boardPermissions).toHaveLength(1);
expect(parsed.groups[0]?.boardPermissions[0]?.boardRef).toBe("board-uuid-1");
});
test("preserves encryption key", () => {
const bundle = createMinimalConfigBundle();
const json = JSON.stringify(bundle);
const parsed = parseConfigBundleJson(json);
expect(parsed.encryptionKey).toBe("a".repeat(64));
});
test("preserves server settings", () => {
const bundle = createMinimalConfigBundle();
const json = JSON.stringify(bundle);
const parsed = parseConfigBundleJson(json);
expect(parsed.serverSettings).toEqual({
board: { enableStatusByDefault: true, forceDisableStatus: false },
docker: { targetBoardName: null, readHomepageLabels: true },
});
});
test("preserves users and group memberships", () => {
const bundle = createMinimalConfigBundle();
const json = JSON.stringify(bundle);
const parsed = parseConfigBundleJson(json);
expect(parsed.users).toHaveLength(1);
expect(parsed.users![0]?.email).toBe("admin@example.com");
expect(parsed.users![0]?.provider).toBe("credentials");
expect(parsed.users![0]?.homeBoardRef).toBe("board-uuid-1");
expect(parsed.groups[0]?.memberRefs).toEqual(["user-1"]);
expect(parsed.groups[0]?.ownerRef).toBe("user-1");
});
test("accepts bundle without users (backward compat)", () => {
const { users: _users, ...bundleWithoutUsers } = createMinimalConfigBundle();
const json = JSON.stringify(bundleWithoutUsers);
const parsed = parseConfigBundleJson(json);
expect(parsed.users).toBeUndefined();
});
});
describe("assessBundleCompatibility", () => {
test("marks valid bundle as compatible", () => {
const bundle = createMinimalConfigBundle();
const result = assessBundleCompatibility(bundle, "1.5.0");
expect(result.compatibility.status).toBe("compatible");
expect(result.bundle).not.toBeNull();
expect(result.compatibility.issues).toHaveLength(0);
});
test("marks compatible even with version mismatch (different Homarr version)", () => {
const bundle = createMinimalConfigBundle({ homarrVersion: "1.4.0" });
const result = assessBundleCompatibility(bundle, "1.5.0");
expect(result.compatibility.status).toBe("compatible");
expect(result.compatibility.issues).toHaveLength(1);
expect(result.compatibility.issues[0]).toContain("1.4.0");
});
test("rejects unsupported format version", () => {
const bundle = { ...createMinimalConfigBundle(), version: "3.0" };
const result = assessBundleCompatibility(bundle, "1.5.0");
expect(result.compatibility.status).toBe("unsupportedVersion");
expect(result.bundle).toBeNull();
});
test("rejects non-object input", () => {
const result = assessBundleCompatibility(null, "1.5.0");
expect(result.compatibility.status).toBe("invalidStructure");
expect(result.bundle).toBeNull();
});
test("rejects malformed structure", () => {
const result = assessBundleCompatibility({ version: "2.0", type: "full-config" }, "1.5.0");
expect(result.compatibility.status).toBe("invalidStructure");
});
});
@@ -1,118 +0,0 @@
import type { HomarrConfigBundle } from "./schema";
import { homarrConfigBundleSchema } from "./schema";
export const CONFIG_BUNDLE_FORMAT_VERSION = "2.0";
export const SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS = [CONFIG_BUNDLE_FORMAT_VERSION] as const;
export type BundleCompatibilityStatus = "compatible" | "unsupportedVersion" | "invalidStructure";
export type BundleCompatibility = {
status: BundleCompatibilityStatus;
bundleVersion: string | null;
bundleHomarrVersion: string | null;
currentHomarrVersion: string;
issues: string[];
};
const versionSupported = (version: string) =>
(SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS as readonly string[]).includes(version);
export const parseConfigBundleJson = (content: string): HomarrConfigBundle => {
const parsed: unknown = JSON.parse(content);
return homarrConfigBundleSchema.parse(parsed);
};
export const assessBundleCompatibility = (
raw: unknown,
currentHomarrVersion: string,
): { bundle: HomarrConfigBundle | null; compatibility: BundleCompatibility } => {
const issues: string[] = [];
if (raw === null || typeof raw !== "object") {
return {
bundle: null,
compatibility: {
status: "invalidStructure",
bundleVersion: null,
bundleHomarrVersion: null,
currentHomarrVersion,
issues: ["File is not a valid JSON object"],
},
};
}
const record = raw as Record<string, unknown>;
const bundleVersion = typeof record.version === "string" ? record.version : null;
const bundleHomarrVersion = typeof record.homarrVersion === "string" ? record.homarrVersion : null;
if (bundleVersion && !versionSupported(bundleVersion)) {
issues.push(
`Bundle format version "${bundleVersion}" is not supported. This instance expects version ${CONFIG_BUNDLE_FORMAT_VERSION}.`,
);
return {
bundle: null,
compatibility: {
status: "unsupportedVersion",
bundleVersion,
bundleHomarrVersion,
currentHomarrVersion,
issues,
},
};
}
const parseResult = homarrConfigBundleSchema.safeParse(raw);
if (!parseResult.success) {
issues.push("Bundle structure does not match the current Homarr config schema.");
issues.push("Ensure the exporting instance was upgraded and migrations have run before export.");
return {
bundle: null,
compatibility: {
status: "invalidStructure",
bundleVersion,
bundleHomarrVersion,
currentHomarrVersion,
issues,
},
};
}
const bundle = parseResult.data;
if (bundle.homarrVersion !== currentHomarrVersion) {
issues.push(
`Exported from Homarr ${bundle.homarrVersion}; this instance is ${currentHomarrVersion}. Import is allowed when the bundle format matches.`,
);
}
return {
bundle,
compatibility: {
status: "compatible",
bundleVersion: bundle.version,
bundleHomarrVersion: bundle.homarrVersion,
currentHomarrVersion,
issues,
},
};
};
export const parseAndValidateBundle = (
content: string,
currentHomarrVersion: string,
): HomarrConfigBundle => {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new Error("Invalid JSON");
}
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
if (!bundle || compatibility.status !== "compatible") {
throw new Error(compatibility.issues.join(" "));
}
return bundle;
};
@@ -1,59 +0,0 @@
/**
* Declarative field definitions for entities in the config bundle.
*
* To add a new user preference:
* 1. Add the column to the DB schema (migration)
* 2. Add the field name to USER_DIRECT_FIELDS below
* That's it — export, import, and schema all derive from this.
*
* To add a new FK reference on users:
* 1. Add the column to the DB schema (migration)
* 2. Add an entry to USER_REF_FIELDS below (bundleKey → dbColumn → refMap name)
*/
/**
* User fields copied directly between bundle and DB (no transformation).
* Adding a new preference here automatically includes it in export AND import.
*/
export const USER_DIRECT_FIELDS = [
"name",
"email",
"password",
"provider",
"colorScheme",
"firstDayOfWeek",
"openSearchInNewTab",
"ddgBangs",
"pingIconsEnabled",
] as const;
export type UserDirectField = (typeof USER_DIRECT_FIELDS)[number];
/**
* User FK reference fields: bundle uses `xxxRef`, DB uses `xxxId`.
* Key = bundle field name (with "Ref" suffix)
* Value = which ref map to use during import (matches the entity group name)
*/
export const USER_REF_FIELDS = {
homeBoardRef: "boards",
mobileHomeBoardRef: "boards",
defaultSearchEngineRef: "searchEngines",
} as const;
export type UserRefField = keyof typeof USER_REF_FIELDS;
/**
* Derives the DB column name from a bundle ref field name.
* "homeBoardRef" → "homeBoardId"
*/
export const refFieldToDbColumn = (refField: string): string =>
refField.replace(/Ref$/, "Id");
/**
* Group FK reference fields.
*/
export const GROUP_REF_FIELDS = {
ownerRef: "users",
homeBoardRef: "boards",
mobileHomeBoardRef: "boards",
} as const;
@@ -1,215 +0,0 @@
import SuperJSON from "superjson";
import { env } from "@homarr/common/env";
import type { Database } from "@homarr/db";
import {
boardGroupPermissions,
groupMembers,
groupPermissions,
groups,
integrationGroupPermissions,
integrations,
integrationSecrets,
searchEngines,
serverSettings,
users,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import { CONFIG_BUNDLE_FORMAT_VERSION } from "../config-bundle-compat";
import { USER_DIRECT_FIELDS, USER_REF_FIELDS } from "../entity-fields";
import { groupByKey } from "../resolve-entities";
import type { ConfigBundleUser, HomarrBundleApp, HomarrConfigBundle } from "../schema";
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
import { loadBoardGraphAsync } from "./load-board-graph";
import { buildBoardSettings } from "./serialize-board";
/**
* Exports a user row using USER_DIRECT_FIELDS and USER_REF_FIELDS.
* Adding a new preference to USER_DIRECT_FIELDS automatically includes it here.
*/
const exportUserRow = (user: Record<string, unknown>): ConfigBundleUser => {
const bundle: Record<string, unknown> = { ref: user.id };
for (const field of USER_DIRECT_FIELDS) {
bundle[field] = user[field] ?? null;
}
for (const refField of Object.keys(USER_REF_FIELDS)) {
const dbCol = refField.replace(/Ref$/, "Id");
bundle[refField] = user[dbCol] ?? null;
}
return bundle as unknown as ConfigBundleUser;
};
export const exportFullConfigAsync = async (
db: Database,
homarrVersion: string,
): Promise<{ content: string; filename: string }> => {
const allBoards = await db.query.boards.findMany();
const allApps = await db.query.apps.findMany();
const allIntegrations = await db.select().from(integrations);
const allSecrets = await db.select().from(integrationSecrets);
const allServerSettings = await db.select().from(serverSettings);
const allSearchEngines = await db.select().from(searchEngines);
const allGroups = await db.select().from(groups);
const allGroupPermissions = await db.select().from(groupPermissions);
const allBoardGroupPermissions = await db.select().from(boardGroupPermissions);
const allIntegrationGroupPermissions = await db.select().from(integrationGroupPermissions);
const allUsers = await db.select().from(users);
const allGroupMembers = await db.select().from(groupMembers);
const secretsByIntegration = groupByKey(allSecrets, (s) => s.integrationId);
const bundleIntegrations = allIntegrations.map((integration) => ({
ref: integration.id,
kind: integration.kind,
name: integration.name,
url: integration.url,
secrets: (secretsByIntegration.get(integration.id) ?? []).map((s) => ({
kind: s.kind as IntegrationSecretKind,
value: s.value,
})),
}));
const bundleApps: HomarrBundleApp[] = allApps.map((app) => ({
ref: app.id,
name: app.name,
href: app.href,
iconUrl: app.iconUrl,
description: app.description,
pingUrl: app.pingUrl,
}));
const appIdSet = new Set(allApps.map((a) => a.id));
const appIdToRef = new Map(allApps.map((app) => [app.id, app.id] as const));
const bundleBoards = [];
for (const boardRow of allBoards) {
const boardGraph = await loadBoardGraphAsync(db, boardRow.id);
if (!boardGraph) continue;
const bundleItems = boardGraph.items.map((item) => {
const options = parseStoredValue<Record<string, unknown>>(item.options, {});
const advancedOptions = itemAdvancedOptionsSchema.parse(
parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})),
);
const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record<string, unknown>;
const appRef = typeof options.appId === "string" && appIdSet.has(options.appId) ? options.appId : undefined;
return {
ref: item.id,
kind: item.kind,
options: portableOptions,
advancedOptions,
integrationRefs: item.integrations.map(({ integrationId }) => integrationId),
appRef,
layouts: item.layouts.map((layout) => ({
layoutRef: layout.layoutId,
sectionRef: layout.sectionId,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
})),
};
});
bundleBoards.push({
ref: boardRow.id,
name: boardGraph.name,
settings: buildBoardSettings(boardGraph),
layouts: boardGraph.layouts.map((layout) => ({
ref: layout.id,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
})),
sections: boardGraph.sections.map((section) => ({
ref: section.id,
kind: section.kind,
name: section.name,
yOffset: section.yOffset,
xOffset: section.xOffset,
options: section.options ? (parseStoredValue(section.options, {}) as Record<string, unknown>) : undefined,
layouts:
section.layouts.length > 0
? section.layouts.map((layout) => ({
layoutRef: layout.layoutId,
parentSectionRef: layout.parentSectionId ?? section.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
}))
: undefined,
})),
items: bundleItems,
});
}
const settingsRecord: Record<string, unknown> = {};
for (const setting of allServerSettings) {
settingsRecord[setting.settingKey] = SuperJSON.parse(setting.value);
}
const bundleSearchEngines = allSearchEngines.map((se) => ({
ref: se.id,
iconUrl: se.iconUrl,
name: se.name,
short: se.short,
description: se.description,
urlTemplate: se.urlTemplate,
type: se.type,
integrationRef: se.integrationId,
}));
const permissionsByGroup = groupByKey(allGroupPermissions, (gp) => gp.groupId);
const boardPermsByGroup = groupByKey(allBoardGroupPermissions, (bp) => bp.groupId);
const integrationPermsByGroup = groupByKey(allIntegrationGroupPermissions, (ip) => ip.groupId);
const membersByGroup = groupByKey(allGroupMembers, (gm) => gm.groupId);
const bundleGroups = allGroups.map((group) => ({
ref: group.id,
name: group.name,
position: group.position,
ownerRef: group.ownerId ?? null,
homeBoardRef: group.homeBoardId ?? null,
mobileHomeBoardRef: group.mobileHomeBoardId ?? null,
permissions: (permissionsByGroup.get(group.id) ?? []).map((gp) => gp.permission),
boardPermissions: (boardPermsByGroup.get(group.id) ?? []).map((bp) => ({
boardRef: bp.boardId,
permission: bp.permission,
})),
integrationPermissions: (integrationPermsByGroup.get(group.id) ?? []).map((ip) => ({
integrationRef: ip.integrationId,
permission: ip.permission,
})),
memberRefs: (membersByGroup.get(group.id) ?? []).map((gm) => gm.userId),
}));
const bundleUsers = allUsers.map((user) => exportUserRow(user as unknown as Record<string, unknown>));
const bundle: HomarrConfigBundle = {
version: CONFIG_BUNDLE_FORMAT_VERSION,
type: "full-config",
exportedAt: new Date().toISOString(),
homarrVersion,
encryptionKey: env.SECRET_ENCRYPTION_KEY ?? "0".repeat(64),
boards: bundleBoards,
apps: bundleApps,
integrations: bundleIntegrations,
serverSettings: settingsRecord,
searchEngines: bundleSearchEngines,
groups: bundleGroups,
users: bundleUsers,
};
const now = new Date().toISOString().slice(0, 10);
return {
content: JSON.stringify(bundle, null, 2),
filename: `homarr-config-${now}.json`,
};
};
@@ -1,23 +0,0 @@
import type { Database } from "@homarr/db";
import { loadBoardGraphAsync } from "./load-board-graph";
import { serializeBoardToBundleAsync } from "./serialize-board";
import { bundleFilenameForBoard, bundleToJson } from "./to-json";
export const exportBoardBundleAsync = async (db: Database, boardId: string, homarrVersion: string) => {
const board = await loadBoardGraphAsync(db, boardId);
if (!board) {
return null;
}
const bundle = await serializeBoardToBundleAsync(db, board, homarrVersion);
return {
bundle,
filename: bundleFilenameForBoard(board.name),
content: bundleToJson(bundle),
};
};
export { loadBoardGraphAsync } from "./load-board-graph";
export { serializeBoardToBundleAsync } from "./serialize-board";
export { bundleToJson, bundleFilenameForBoard } from "./to-json";
@@ -1,35 +0,0 @@
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { boards } from "@homarr/db/schema";
export const loadBoardGraphAsync = async (db: Database, boardId: string) => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
layouts: true,
sections: {
with: {
layouts: true,
},
},
items: {
with: {
layouts: true,
integrations: {
with: {
integration: {
with: {
secrets: true,
},
},
},
},
},
},
},
});
return board;
};
export type LoadedBoardGraph = NonNullable<Awaited<ReturnType<typeof loadBoardGraphAsync>>>;
@@ -1,42 +0,0 @@
import type { Database } from "@homarr/db";
import {
groups,
integrationSecrets,
integrations,
items,
layouts,
searchEngines,
sections,
serverSettings,
users,
} from "@homarr/db/schema";
import type { ConfigExportPreview } from "../types";
export const previewExportFullConfigAsync = async (db: Database): Promise<ConfigExportPreview> => {
const allBoards = await db.query.boards.findMany();
const allApps = await db.query.apps.findMany();
const allIntegrations = await db.select().from(integrations);
const allSecrets = await db.select().from(integrationSecrets);
const allItems = await db.select().from(items);
const allSections = await db.select().from(sections);
const allLayouts = await db.select().from(layouts);
const allSearchEngines = await db.select().from(searchEngines);
const allGroups = await db.select().from(groups);
const allUsers = await db.select().from(users);
const allServerSettings = await db.select().from(serverSettings);
return {
boards: allBoards.length,
apps: allApps.length,
integrations: allIntegrations.length,
secrets: allSecrets.length,
widgets: allItems.length,
sections: allSections.length,
layouts: allLayouts.length,
searchEngines: allSearchEngines.length,
groups: allGroups.length,
users: allUsers.length,
serverSettings: allServerSettings.length,
};
};
@@ -1,168 +0,0 @@
import { inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import type { HomarrBundle, HomarrBundleApp, HomarrBundleIntegration } from "../schema";
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
import type { LoadedBoardGraph } from "./load-board-graph";
export const buildBoardSettings = (board: LoadedBoardGraph) => ({
pageTitle: board.pageTitle,
metaTitle: board.metaTitle,
logoImageUrl: board.logoImageUrl,
faviconImageUrl: board.faviconImageUrl,
backgroundImageUrl: board.backgroundImageUrl,
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
customCss: board.customCss,
iconColor: board.iconColor,
itemRadius: board.itemRadius,
disableStatus: board.disableStatus,
isPublic: board.isPublic,
});
const walkValues = (value: unknown, onString: (candidate: string) => void) => {
if (typeof value === "string") {
onString(value);
return;
}
if (Array.isArray(value)) {
value.forEach((entry) => walkValues(entry, onString));
return;
}
if (value !== null && typeof value === "object") {
Object.values(value).forEach((entry) => walkValues(entry, onString));
}
};
export const serializeBoardToBundleAsync = async (
db: Database,
board: LoadedBoardGraph,
homarrVersion: string,
): Promise<HomarrBundle> => {
const appIdCandidates = new Set<string>();
const integrationRecords = new Map<
string,
{
id: string;
kind: LoadedBoardGraph["items"][number]["integrations"][number]["integration"]["kind"];
name: string;
url: string;
secretKinds: IntegrationSecretKind[];
}
>();
board.items.forEach((item) => {
const options = parseStoredValue<Record<string, unknown>>(item.options, {});
walkValues(options, (candidate) => appIdCandidates.add(candidate));
item.integrations.forEach(({ integration }) => {
integrationRecords.set(integration.id, {
id: integration.id,
kind: integration.kind,
name: integration.name,
url: integration.url,
secretKinds: integration.secrets.map((secret) => secret.kind),
});
});
});
const dbApps =
appIdCandidates.size > 0
? await db.query.apps.findMany({
where: inArray(apps.id, [...appIdCandidates]),
})
: [];
const appIdToRef = new Map(dbApps.map((app) => [app.id, app.id] as const));
const bundleApps: HomarrBundleApp[] = dbApps.map((app) => ({
ref: app.id,
name: app.name,
href: app.href,
iconUrl: app.iconUrl,
description: app.description,
pingUrl: app.pingUrl,
}));
const bundleIntegrations: HomarrBundleIntegration[] = [...integrationRecords.values()].map((integration) => ({
ref: integration.id,
kind: integration.kind,
name: integration.name,
url: integration.url,
secretKinds: integration.secretKinds,
secrets: "REDACTED" as const,
}));
const bundleItems = board.items.map((item) => {
const options = parseStoredValue<Record<string, unknown>>(item.options, {});
const advancedOptions = itemAdvancedOptionsSchema.parse(
parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})),
);
const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record<string, unknown>;
const appRef = typeof options.appId === "string" ? appIdToRef.get(options.appId) : undefined;
return {
ref: item.id,
kind: item.kind,
options: portableOptions,
advancedOptions,
integrationRefs: item.integrations.map(({ integrationId }) => integrationId),
appRef,
layouts: item.layouts.map((layout) => ({
layoutRef: layout.layoutId,
sectionRef: layout.sectionId,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
})),
};
});
return {
version: "1.0",
exportedAt: new Date().toISOString(),
homarrVersion,
boards: [
{
name: board.name,
settings: buildBoardSettings(board),
layouts: board.layouts.map((layout) => ({
ref: layout.id,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
})),
sections: board.sections.map((section) => ({
ref: section.id,
kind: section.kind,
name: section.name,
yOffset: section.yOffset,
xOffset: section.xOffset,
options: section.options ? (parseStoredValue(section.options, {}) as Record<string, unknown>) : undefined,
layouts:
section.layouts.length > 0
? section.layouts.map((layout) => ({
layoutRef: layout.layoutId,
parentSectionRef: layout.parentSectionId ?? section.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
}))
: undefined,
})),
items: bundleItems,
},
],
apps: bundleApps,
integrations: bundleIntegrations,
};
};
@@ -1,10 +0,0 @@
import type { HomarrBundle } from "../schema";
export const bundleToJson = (bundle: HomarrBundle) => {
return JSON.stringify(bundle, null, 2);
};
export const bundleFilenameForBoard = (boardName: string) => {
const sanitized = boardName.replace(/[^A-Za-z0-9-_]/g, "-");
return `homarr-board-${sanitized}.json`;
};
@@ -1,15 +0,0 @@
const ENV_VAR_PATTERN = /\{\{(HOMEPAGE_VAR_[A-Z0-9_]+)\}\}/g;
export const extractHomepageEnvVariables = (content: string): string[] => {
const matches = new Set<string>();
for (const match of content.matchAll(ENV_VAR_PATTERN)) {
matches.add(match[1] ?? "");
}
return [...matches].sort();
};
export const replaceHomepageEnvVariables = (
content: string,
values: Record<string, string>,
): string =>
content.replaceAll(ENV_VAR_PATTERN, (full, varName: string) => values[varName] ?? full);
@@ -1,76 +0,0 @@
import { handleTransactionsAsync } from "@homarr/db";
import type { Database } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { parseServicesYaml } from "./parse-services-yaml";
import { prepareHomepageImport } from "./prepare-homepage-import";
export type HomepageImportReport = {
boardId: string;
created: {
apps: number;
integrations: number;
items: number;
sections: number;
};
warnings: string[];
unmappedWidgetTypes: string[];
};
export const importHomepageServicesAsync = async (
db: Database,
content: string,
boardName: string,
creatorId: string,
createIntegrations: boolean,
): Promise<HomepageImportReport> => {
const parsed = parseServicesYaml(content);
if (!parsed.success) {
throw new Error(parsed.error);
}
const prepared = prepareHomepageImport(parsed.services, boardName, creatorId, createIntegrations);
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
"integrations",
"integrationSecrets",
"boards",
"layouts",
"sections",
"items",
"itemLayouts",
"integrationItems",
]);
insertCollection.apps.push(...prepared.apps);
insertCollection.integrations.push(...prepared.integrations);
insertCollection.integrationSecrets.push(...prepared.integrationSecrets);
insertCollection.boards.push(prepared.board);
insertCollection.layouts.push(...prepared.layouts);
insertCollection.sections.push(...prepared.sections);
insertCollection.items.push(...prepared.items);
insertCollection.itemLayouts.push(...prepared.itemLayouts);
insertCollection.integrationItems.push(...prepared.integrationItems);
await handleTransactionsAsync(db, {
async handleAsync(innerDb) {
await insertCollection.insertAllAsync(innerDb);
},
handleSync(innerDb) {
insertCollection.insertAll(innerDb);
},
});
return {
boardId: prepared.board.id,
created: {
apps: prepared.apps.length,
integrations: prepared.integrations.length,
items: prepared.items.length,
sections: prepared.sections.length,
},
warnings: prepared.warnings,
unmappedWidgetTypes: prepared.unmappedWidgetTypes,
};
};
@@ -1,5 +0,0 @@
export { parseServicesYaml } from "./parse-services-yaml";
export { homepageWidgetMap, type HomepageWidgetMapping } from "@homarr/definitions";
export { prepareHomepageImport, resolveHomepageIconUrl, type PreparedHomepageImport } from "./prepare-homepage-import";
export { importHomepageServicesAsync, type HomepageImportReport } from "./import";
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
@@ -1,111 +0,0 @@
import { describe, expect, test } from "vitest";
import { parseServicesYaml } from "./parse-services-yaml";
const sampleYaml = `
- Media:
- Sonarr:
icon: sonarr.png
href: http://sonarr.host/
description: Series management
ping: sonarr.host
widget:
type: sonarr
url: http://sonarr.host
key: apikeyhere
- Radarr:
icon: radarr.png
href: http://radarr.host/
widgets:
- type: radarr
url: http://radarr.host
key: radarrkey
- Downloads:
- qBittorrent:
icon: qbittorrent.png
href: http://qbit.host/
widget:
type: qbittorrent
url: http://qbit.host
username: admin
password: pass123
`;
describe("parseServicesYaml", () => {
test("parses groups, services, singular widget, and plural widgets", () => {
const result = parseServicesYaml(sampleYaml);
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.services).toHaveLength(3);
const sonarr = result.services.find((service) => service.name === "Sonarr");
expect(sonarr).toMatchObject({
group: "Media",
href: "http://sonarr.host/",
ping: "sonarr.host",
});
expect(sonarr?.widgets).toHaveLength(1);
expect(sonarr?.widgets[0]).toMatchObject({ type: "sonarr", key: "apikeyhere" });
const radarr = result.services.find((service) => service.name === "Radarr");
expect(radarr?.widgets).toHaveLength(1);
expect(radarr?.widgets[0]?.type).toBe("radarr");
const qbit = result.services.find((service) => service.name === "qBittorrent");
expect(qbit?.group).toBe("Downloads");
expect(qbit?.widgets[0]).toMatchObject({ type: "qbittorrent", fields: { username: "admin" } });
});
test("flattens nested groups with separator", () => {
const yaml = `
- Parent:
- Child:
- Service:
href: http://service.local
`;
const result = parseServicesYaml(yaml);
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.services[0]?.group).toBe("Parent / Child");
expect(result.services[0]?.name).toBe("Service");
});
test("parses services without widgets", () => {
const yaml = `
- Tools:
- Homepage:
href: http://homepage.local
`;
const result = parseServicesYaml(yaml);
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.services[0]?.widgets).toEqual([]);
});
test("returns empty services for empty content", () => {
expect(parseServicesYaml(" ")).toEqual({ success: true, services: [] });
});
test("handles malformed yaml gracefully", () => {
const result = parseServicesYaml("invalid: yaml: [");
expect(result.success).toBe(false);
if (result.success) {
return;
}
expect(result.error).toBe("Invalid YAML");
});
test("rejects non-array root documents", () => {
const result = parseServicesYaml("name: not-an-array");
expect(result).toEqual({ success: false, error: "Homepage services.yaml must be a YAML array" });
});
});
@@ -1,111 +0,0 @@
import { parse as parseYaml } from "yaml";
import type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
const extractWidgets = (config: Record<string, unknown>): HomepageWidget[] => {
const singular = config.widget;
const plural = config.widgets;
if (Array.isArray(plural)) {
return plural.filter(isWidgetConfig).map(normalizeWidget);
}
if (isWidgetConfig(singular)) {
return [normalizeWidget(singular)];
}
return [];
};
const isWidgetConfig = (value: unknown): value is Record<string, unknown> & { type: string } => {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
"type" in value &&
typeof value.type === "string"
);
};
const normalizeWidget = (widget: Record<string, unknown>): HomepageWidget => {
const { type, url, key, ...rest } = widget;
return {
type: String(type),
...(typeof url === "string" ? { url } : {}),
...(typeof key === "string" ? { key } : {}),
fields: rest,
};
};
const isServiceConfig = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
const parseService = (group: string, name: string, config: Record<string, unknown>): HomepageService => {
const icon = typeof config.icon === "string" ? config.icon : undefined;
const href = typeof config.href === "string" ? config.href : undefined;
const description = typeof config.description === "string" ? config.description : undefined;
const ping = typeof config.ping === "string" ? config.ping : undefined;
const siteMonitor = typeof config.siteMonitor === "string" ? config.siteMonitor : undefined;
return {
group,
name,
icon,
href,
description,
ping,
siteMonitor,
widgets: extractWidgets(config),
};
};
const collectServices = (entries: unknown[], groupPath: string): HomepageService[] => {
const services: HomepageService[] = [];
for (const entry of entries) {
if (!isServiceConfig(entry)) {
continue;
}
const keys = Object.keys(entry);
if (keys.length !== 1) {
continue;
}
const name = keys[0] ?? "";
const value = entry[name];
if (Array.isArray(value)) {
const nestedGroup = groupPath ? `${groupPath} / ${name}` : name;
services.push(...collectServices(value, nestedGroup));
continue;
}
if (isServiceConfig(value)) {
services.push(parseService(groupPath.length > 0 ? groupPath : "Services", name, value));
}
}
return services;
};
export const parseServicesYaml = (content: string): ParseServicesYamlResult => {
const trimmed = content.trim();
if (trimmed.length === 0) {
return { success: true, services: [] };
}
let parsed: unknown;
try {
parsed = parseYaml(trimmed);
} catch {
return { success: false, error: "Invalid YAML" };
}
if (!Array.isArray(parsed)) {
return { success: false, error: "Homepage services.yaml must be a YAML array" };
}
return { success: true, services: collectServices(parsed, "") };
};
@@ -1,346 +0,0 @@
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import type { InferInsertModel } from "@homarr/db";
import type {
apps,
boards,
integrationItems,
integrationSecrets,
integrations,
itemLayouts,
items,
layouts,
sections,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { emptySuperJSON, homepageWidgetMap, integrationDefs } from "@homarr/definitions";
import { stringifyForDb } from "../utils";
import type { HomepageService, HomepageWidget } from "./types";
const DASHBOARD_ICON_CDN = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg";
const ENV_VAR_PATTERN = /^\$\{[^}]+\}$/;
const COLUMN_COUNT = 10;
const WIDGET_WIDTH = 2;
export type PreparedHomepageImport = {
apps: InferInsertModel<typeof apps>[];
integrations: InferInsertModel<typeof integrations>[];
integrationSecrets: InferInsertModel<typeof integrationSecrets>[];
board: InferInsertModel<typeof boards>;
layouts: InferInsertModel<typeof layouts>[];
sections: InferInsertModel<typeof sections>[];
items: InferInsertModel<typeof items>[];
itemLayouts: InferInsertModel<typeof itemLayouts>[];
integrationItems: InferInsertModel<typeof integrationItems>[];
warnings: string[];
unmappedWidgetTypes: string[];
};
type GridPosition = { x: number; y: number };
const advanceGrid = (grid: GridPosition, width: number): GridPosition => {
const nextX = grid.x + width;
if (nextX >= COLUMN_COUNT) {
return { x: 0, y: grid.y + 1 };
}
return { x: nextX, y: grid.y };
};
const isEnvVarReference = (value: string) => ENV_VAR_PATTERN.test(value.trim());
export const resolveHomepageIconUrl = (icon: string | undefined, serviceName: string): string => {
if (!icon || icon.trim().length === 0) {
const fallback = serviceName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "");
return `${DASHBOARD_ICON_CDN}/${fallback || "generic"}.svg`;
}
if (icon.startsWith("http://") || icon.startsWith("https://")) {
return icon;
}
const baseName = icon.replace(/\.(png|webp|svg|jpg|jpeg)$/i, "");
return `${DASHBOARD_ICON_CDN}/${baseName}.svg`;
};
const resolvePingUrl = (service: HomepageService): string | null => {
const raw = service.ping ?? service.siteMonitor;
if (!raw || raw.trim().length === 0) {
return null;
}
if (raw.startsWith("http://") || raw.startsWith("https://")) {
return raw;
}
return `http://${raw}`;
};
const extractSecretValue = (widget: HomepageWidget, homepageField: string): string | undefined => {
const value = widget.fields[homepageField] ?? (homepageField === "key" ? widget.key : undefined);
return typeof value === "string" && value.length > 0 ? value : undefined;
};
const descriptionDisplayModes: Record<string, string> = {
present: "tooltip",
absent: "hidden",
};
type WidgetProcessingContext = {
service: HomepageService;
appId: string;
sectionId: string;
boardId: string;
layoutId: string;
gridState: Map<string, GridPosition>;
integrationRows: InferInsertModel<typeof integrations>[];
integrationSecretRows: InferInsertModel<typeof integrationSecrets>[];
itemRows: InferInsertModel<typeof items>[];
itemLayoutRows: InferInsertModel<typeof itemLayouts>[];
integrationItemRows: InferInsertModel<typeof integrationItems>[];
warnings: string[];
unmappedWidgetTypesSet: Set<string>;
};
const processServiceWidgets = (ctx: WidgetProcessingContext) => {
for (const widget of ctx.service.widgets) {
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
if (!mapping) {
ctx.unmappedWidgetTypesSet.add(widget.type);
continue;
}
const integrationId = createId();
const integrationUrl =
(typeof widget.url === "string" && widget.url.length > 0 ? widget.url : ctx.service.href) ?? "";
if (integrationUrl.length === 0) {
ctx.warnings.push(`Skipped integration for "${ctx.service.name}" (${widget.type}): missing URL`);
continue;
}
ctx.integrationRows.push({
id: integrationId,
kind: mapping.integrationKind,
name: ctx.service.name,
url: integrationUrl,
appId: ctx.appId,
});
processSecrets(ctx, widget, mapping, integrationId);
validateSecrets(ctx, mapping, integrationId);
placeWidgetItem(ctx, mapping.widgetKind, integrationId);
}
};
const processSecrets = (
ctx: WidgetProcessingContext,
widget: HomepageWidget,
mapping: { secretFieldMap: Record<string, IntegrationSecretKind>; integrationKind: string },
integrationId: string,
) => {
for (const [homepageField, secretKind] of Object.entries(mapping.secretFieldMap)) {
const rawValue = extractSecretValue(widget, homepageField);
if (!rawValue) continue;
if (isEnvVarReference(rawValue)) {
ctx.warnings.push(
`Unresolved secret for "${ctx.service.name}" (${widget.type}): ${homepageField} references an environment variable`,
);
continue;
}
ctx.integrationSecretRows.push({
integrationId,
kind: secretKind as IntegrationSecretKind,
value: encryptSecret(rawValue),
});
}
};
const validateSecrets = (
ctx: WidgetProcessingContext,
mapping: { integrationKind: string },
integrationId: string,
) => {
const requiredSecretSets = integrationDefs[mapping.integrationKind as keyof typeof integrationDefs].secretKinds;
const providedKinds = new Set(
ctx.integrationSecretRows.filter((s) => s.integrationId === integrationId).map((s) => s.kind),
);
const hasRequiredSecrets = requiredSecretSets.some(
(secretSet) => secretSet.length === 0 || secretSet.every((kind) => providedKinds.has(kind)),
);
if (!hasRequiredSecrets && requiredSecretSets.every((set) => set.length > 0)) {
ctx.warnings.push(`Integration "${ctx.service.name}" (${mapping.integrationKind}) may be missing required credentials`);
}
};
const placeWidgetItem = (ctx: WidgetProcessingContext, widgetKind: string, integrationId: string) => {
const widgetItemId = createId();
const grid = ctx.gridState.get(ctx.sectionId) ?? { x: 0, y: 0 };
ctx.itemRows.push({
id: widgetItemId,
boardId: ctx.boardId,
kind: widgetKind as never,
options: emptySuperJSON,
advancedOptions: emptySuperJSON,
});
ctx.itemLayoutRows.push({
itemId: widgetItemId,
sectionId: ctx.sectionId,
layoutId: ctx.layoutId,
width: WIDGET_WIDTH,
height: 1,
xOffset: grid.x,
yOffset: grid.y,
});
ctx.gridState.set(ctx.sectionId, advanceGrid(grid, WIDGET_WIDTH));
ctx.integrationItemRows.push({
itemId: widgetItemId,
integrationId,
});
};
export const prepareHomepageImport = (
services: HomepageService[],
boardName: string,
creatorId: string,
createIntegrations: boolean,
): PreparedHomepageImport => {
const warnings: string[] = [];
const unmappedWidgetTypesSet = new Set<string>();
const boardId = createId();
const layoutId = createId();
const board: InferInsertModel<typeof boards> = {
id: boardId,
name: boardName,
creatorId,
isPublic: false,
};
const layoutRows: InferInsertModel<typeof layouts>[] = [
{ id: layoutId, boardId, name: "Base", columnCount: COLUMN_COUNT, breakpoint: 0 },
];
const groupNames = [...new Set(services.map((service) => service.group || "Services"))];
const sectionByGroup = new Map<string, string>();
const sectionRows: InferInsertModel<typeof sections>[] = groupNames.map((groupName, index) => {
const sectionId = createId();
sectionByGroup.set(groupName, sectionId);
return {
id: sectionId,
boardId,
kind: "category" as const,
name: groupName || "Services",
xOffset: 0,
yOffset: index,
};
});
const appRows: InferInsertModel<typeof apps>[] = [];
const integrationRows: InferInsertModel<typeof integrations>[] = [];
const integrationSecretRows: InferInsertModel<typeof integrationSecrets>[] = [];
const itemRows: InferInsertModel<typeof items>[] = [];
const itemLayoutRows: InferInsertModel<typeof itemLayouts>[] = [];
const integrationItemRows: InferInsertModel<typeof integrationItems>[] = [];
const gridState = new Map<string, GridPosition>();
for (const service of services) {
const groupKey = service.group || "Services";
const sectionId = sectionByGroup.get(groupKey);
if (!sectionId) {
warnings.push(`Skipped "${service.name}" because section "${groupKey}" was not found`);
continue;
}
const appId = createId();
const iconUrl = resolveHomepageIconUrl(service.icon, service.name);
const pingUrl = resolvePingUrl(service);
appRows.push({
id: appId,
name: service.name,
href: service.href ?? null,
iconUrl,
description: service.description ?? null,
pingUrl,
});
const grid = gridState.get(sectionId) ?? { x: 0, y: 0 };
const itemId = createId();
itemRows.push({
id: itemId,
boardId,
kind: "app",
options: stringifyForDb({
appId,
openInNewTab: true,
showTitle: true,
layout: "column",
descriptionDisplayMode: descriptionDisplayModes[service.description ? "present" : "absent"],
pingEnabled: pingUrl !== null,
}),
advancedOptions: emptySuperJSON,
});
itemLayoutRows.push({
itemId,
sectionId,
layoutId,
width: 1,
height: 1,
xOffset: grid.x,
yOffset: grid.y,
});
gridState.set(sectionId, advanceGrid(grid, 1));
if (!createIntegrations) {
for (const widget of service.widgets) {
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
if (!mapping) {
unmappedWidgetTypesSet.add(widget.type);
}
}
continue;
}
processServiceWidgets({
service,
appId,
sectionId,
boardId,
layoutId,
gridState,
integrationRows,
integrationSecretRows,
itemRows,
itemLayoutRows,
integrationItemRows,
warnings,
unmappedWidgetTypesSet,
});
}
return {
apps: appRows,
integrations: integrationRows,
integrationSecrets: integrationSecretRows,
board,
layouts: layoutRows,
sections: sectionRows,
items: itemRows,
itemLayouts: itemLayoutRows,
integrationItems: integrationItemRows,
warnings,
unmappedWidgetTypes: [...unmappedWidgetTypesSet].sort(),
};
};
@@ -1,21 +0,0 @@
export interface HomepageService {
group: string;
name: string;
icon?: string;
href?: string;
description?: string;
ping?: string;
siteMonitor?: string;
widgets: HomepageWidget[];
}
export interface HomepageWidget {
type: string;
url?: string;
key?: string;
fields: Record<string, unknown>;
}
export type ParseServicesYamlResult =
| { success: true; services: HomepageService[] }
| { success: false; error: string };
@@ -1,38 +0,0 @@
import { describe, expect, test } from "vitest";
import { homepageWidgetMap, integrationDefs, integrationKinds } from "@homarr/definitions";
describe("homepageWidgetMap", () => {
test("returns mapping for known homepage widget types", () => {
const sonarr = homepageWidgetMap.sonarr;
expect(sonarr).toMatchObject({
integrationKind: "sonarr",
widgetKind: "downloads",
secretFieldMap: { key: "apiKey" },
});
});
test("returns null for unknown widget types", () => {
expect(homepageWidgetMap.unknownservice).toBeUndefined();
expect(homepageWidgetMap.bazarr).toBeNull();
expect(homepageWidgetMap.tautulli).toBeNull();
});
test("only maps integration kinds that exist in integrationDefs", () => {
const invalidKinds: string[] = [];
for (const mapping of Object.values(homepageWidgetMap)) {
if (!mapping) {
continue;
}
if (!integrationKinds.includes(mapping.integrationKind)) {
invalidKinds.push(mapping.integrationKind);
}
expect(integrationDefs[mapping.integrationKind]).toBeDefined();
}
expect(invalidKinds).toEqual([]);
});
});
@@ -1,506 +0,0 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import { decryptSecretWithKey, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import {
boardGroupPermissions,
groupMembers,
groupPermissions,
groups,
integrationGroupPermissions,
integrations,
integrationSecrets,
searchEngines,
serverSettings,
users,
} from "@homarr/db/schema";
import type { apps } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { GROUP_REF_FIELDS, USER_DIRECT_FIELDS, USER_REF_FIELDS, refFieldToDbColumn } from "../entity-fields";
import { resolveEntities } from "../resolve-entities";
import type { HomarrBundleBoard, HomarrConfigBundle } from "../schema";
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
import { loadExistingEntitiesAsync } from "./load-existing-entities";
export type ConfigImportReport = {
boards: number;
apps: number;
integrations: number;
groups: number;
users: number;
searchEngines: number;
warnings: string[];
};
const reEncryptSecret = (encryptedValue: string, sourceKeyHex: string): `${string}.${string}` => {
const sourceKey = Buffer.from(sourceKeyHex, "hex");
const decrypted = decryptSecretWithKey(encryptedValue as `${string}.${string}`, sourceKey);
return encryptSecret(decrypted);
};
const resolveRef = (map: Map<string, string>, ref: string | null | undefined): string | null => {
if (!ref) return null;
return map.get(ref) ?? null;
};
const resolveApps = (
bundle: HomarrConfigBundle,
existingApps: { id: string; name: string; iconUrl: string; href: string | null }[],
) =>
resolveEntities(bundle.apps, existingApps, {
getRef: (app) => app.ref,
match: (bundle, existing) =>
existing.name === bundle.name && existing.iconUrl === bundle.iconUrl && existing.href === bundle.href,
toInsertRow: (app, id): (typeof apps.$inferInsert) => ({
id,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
description: app.description ?? null,
pingUrl: app.pingUrl ?? null,
}),
});
const resolveIntegrations = (
bundle: HomarrConfigBundle,
existingIntegrations: { id: string; kind: string; name: string; url: string }[],
) => {
const result = resolveEntities(bundle.integrations, existingIntegrations, {
getRef: (i) => i.ref,
match: (bundle, existing) =>
existing.kind === bundle.kind && existing.name === bundle.name && existing.url === bundle.url,
toInsertRow: (integration, id): (typeof integrations.$inferInsert) => ({
id,
kind: integration.kind,
name: integration.name,
url: integration.url,
}),
});
const secretRows: (typeof integrationSecrets.$inferInsert)[] = [];
const secretWarnings: string[] = [];
const sourceKey = bundle.encryptionKey;
for (const bundleIntegration of bundle.integrations) {
const newId = result.refToId.get(bundleIntegration.ref);
if (!newId || existingIntegrations.some((e) => e.id === newId)) continue;
for (const secret of bundleIntegration.secrets) {
try {
const reEncrypted = reEncryptSecret(secret.value, sourceKey);
secretRows.push({
integrationId: newId,
kind: secret.kind as IntegrationSecretKind,
value: reEncrypted,
updatedAt: new Date(),
});
} catch {
secretWarnings.push(`Failed to re-encrypt secret "${secret.kind}" for integration "${bundleIntegration.name}"`);
}
}
}
return {
...result,
secretRows,
warnings: [...result.warnings, ...secretWarnings],
};
};
const resolveBoards = (
bundle: HomarrConfigBundle,
existingBoards: { id: string; name: string }[],
) => {
const boardRefToId = new Map<string, string>();
const boardsToCreate = new Set<string>();
const warnings: string[] = [];
const existingBoardNames = new Set(existingBoards.map((b) => b.name));
for (const bundleBoard of bundle.boards) {
if (existingBoardNames.has(bundleBoard.name)) {
const existing = existingBoards.find((b) => b.name === bundleBoard.name);
if (existing) {
boardRefToId.set(bundleBoard.name, existing.id);
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, existing.id);
}
warnings.push(`Board "${bundleBoard.name}" already exists and was skipped`);
continue;
}
const boardId = createId();
boardRefToId.set(bundleBoard.name, boardId);
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, boardId);
boardsToCreate.add(bundleBoard.name);
}
return { boardRefToId, boardsToCreate, warnings };
};
const resolveSearchEngines = (
bundle: HomarrConfigBundle,
existingSearchEngines: { id: string; short: string }[],
integrationRefToId: Map<string, string>,
) => {
const result = resolveEntities(bundle.searchEngines, existingSearchEngines, {
getRef: (se) => se.ref,
match: (bundle, existing) => existing.short === bundle.short,
toInsertRow: (se, id): (typeof searchEngines.$inferInsert) => ({
id,
iconUrl: se.iconUrl,
name: se.name,
short: se.short,
description: se.description ?? null,
urlTemplate: se.urlTemplate ?? null,
type: se.type as never,
integrationId: resolveRef(integrationRefToId, se.integrationRef),
}),
skipMessage: (se) => `Search engine "${se.name}" (short: ${se.short}) already exists and was skipped`,
});
for (const existing of existingSearchEngines) {
result.refToId.set(existing.id, existing.id);
}
return result;
};
/**
* Uses USER_DIRECT_FIELDS and USER_REF_FIELDS to build user insert rows.
* Adding a new user preference to USER_DIRECT_FIELDS automatically
* includes it here — no other changes needed.
*/
const resolveUsers = (
bundle: HomarrConfigBundle,
existingUsers: { id: string; email: string | null }[],
refMaps: Record<string, Map<string, string>>,
) =>
resolveEntities(bundle.users ?? [], existingUsers, {
getRef: (u) => u.ref,
match: (bundle, existing) => Boolean(bundle.email) && existing.email === bundle.email,
toInsertRow: (bundleUser, id): (typeof users.$inferInsert) => {
const row: Record<string, unknown> = { id };
for (const field of USER_DIRECT_FIELDS) {
row[field] = (bundleUser as Record<string, unknown>)[field] ?? null;
}
for (const [refField, mapName] of Object.entries(USER_REF_FIELDS)) {
const dbCol = refFieldToDbColumn(refField);
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleUser as Record<string, unknown>)[refField] as string);
}
return row as typeof users.$inferInsert;
},
});
const resolveGroups = (
bundle: HomarrConfigBundle,
existingGroups: { id: string; name: string }[],
existingMembershipKeys: Set<string>,
refMaps: Record<string, Map<string, string>>,
) => {
const result = resolveEntities(bundle.groups, existingGroups, {
getRef: (g) => g.ref,
match: (bundle, existing) => existing.name === bundle.name,
toInsertRow: (bundleGroup, id): (typeof groups.$inferInsert) => {
const row: Record<string, unknown> = { id, name: bundleGroup.name, position: bundleGroup.position };
for (const [refField, mapName] of Object.entries(GROUP_REF_FIELDS)) {
const dbCol = refFieldToDbColumn(refField);
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleGroup as Record<string, unknown>)[refField] as string);
}
return row as typeof groups.$inferInsert;
},
skipMessage: (g) => `Group "${g.name}" already exists and was skipped`,
});
const groupPermRows: (typeof groupPermissions.$inferInsert)[] = [];
const boardGroupPermRows: (typeof boardGroupPermissions.$inferInsert)[] = [];
const integrationGroupPermRows: (typeof integrationGroupPermissions.$inferInsert)[] = [];
const memberRows: (typeof groupMembers.$inferInsert)[] = [];
const extraWarnings: string[] = [];
for (const bundleGroup of bundle.groups) {
const groupId = result.refToId.get(bundleGroup.ref);
if (!groupId || existingGroups.some((g) => g.id === groupId)) continue;
for (const perm of bundleGroup.permissions) {
groupPermRows.push({ groupId, permission: perm as never });
}
}
for (const bundleGroup of bundle.groups) {
const groupId = result.refToId.get(bundleGroup.ref);
if (!groupId) continue;
for (const bp of bundleGroup.boardPermissions) {
const resolvedBoardId = resolveRef(refMaps.boards!, bp.boardRef);
if (!resolvedBoardId) {
extraWarnings.push(`Board permission for group "${bundleGroup.name}" references unknown board, skipped`);
continue;
}
boardGroupPermRows.push({ groupId, boardId: resolvedBoardId, permission: bp.permission as never });
}
for (const ip of bundleGroup.integrationPermissions) {
const resolvedIntegrationId = resolveRef(refMaps.integrations!, ip.integrationRef);
if (resolvedIntegrationId) {
integrationGroupPermRows.push({ groupId, integrationId: resolvedIntegrationId, permission: ip.permission as never });
}
}
for (const memberRef of bundleGroup.memberRefs ?? []) {
const userId = refMaps.users!.get(memberRef);
if (!userId) continue;
const key = `${groupId}:${userId}`;
if (existingMembershipKeys.has(key)) continue;
memberRows.push({ groupId, userId });
}
}
return {
...result,
groupPermRows,
boardGroupPermRows,
integrationGroupPermRows,
memberRows,
warnings: [...result.warnings, ...extraWarnings],
};
};
const resolveServerSettings = (
bundle: HomarrConfigBundle,
existingSettings: { settingKey: string; value: string }[],
) => {
const existingKeys = new Set(existingSettings.map((s) => s.settingKey));
const toInsert: (typeof serverSettings.$inferInsert)[] = [];
const toUpdate: { key: string; value: string }[] = [];
for (const [key, value] of Object.entries(bundle.serverSettings)) {
const serialized = SuperJSON.stringify(value);
if (existingKeys.has(key)) {
toUpdate.push({ key, value: serialized });
} else {
toInsert.push({ settingKey: key, value: serialized });
}
}
return { toInsert, toUpdate };
};
const buildBoardInserts = (
bundle: HomarrConfigBundle,
boardsToCreate: Set<string>,
boardRefToId: Map<string, string>,
appRefToId: Map<string, string>,
integrationRefToId: Map<string, string>,
creatorId: string | null,
insertCollection: Pick<ReturnType<typeof createDbInsertCollectionForTransaction>, "boards" | "layouts" | "sections" | "sectionLayouts" | "items" | "itemLayouts" | "integrationItems">,
) => {
let boardsCreated = 0;
for (const bundleBoard of bundle.boards) {
if (!boardsToCreate.has(bundleBoard.name)) continue;
const boardId = boardRefToId.get(bundleBoard.name);
if (!boardId) continue;
boardsCreated++;
const sourceBoard = bundleBoard as HomarrBundleBoard;
const layoutRefToId = new Map(sourceBoard.layouts.map((l) => [l.ref, createId()]));
const sectionRefToId = new Map(sourceBoard.sections.map((s) => [s.ref, createId()]));
const itemRefToId = new Map(sourceBoard.items.map((i) => [i.ref, createId()]));
insertCollection.boards.push(buildBoardInsertRow(boardId, sourceBoard.name, creatorId, sourceBoard.settings));
for (const layout of sourceBoard.layouts) {
insertCollection.layouts.push({
id: mustGet(layoutRefToId, layout.ref),
boardId,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
});
}
for (const section of sourceBoard.sections) {
insertCollection.sections.push({
id: mustGet(sectionRefToId, section.ref),
boardId,
kind: section.kind,
name: section.name ?? null,
xOffset: section.xOffset ?? null,
yOffset: section.yOffset ?? null,
options: section.options ? stringifyForDb(section.options) : undefined,
});
for (const layout of section.layouts ?? []) {
insertCollection.sectionLayouts.push({
sectionId: mustGet(sectionRefToId, section.ref),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef),
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
});
}
}
for (const item of sourceBoard.items) {
const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record<string, unknown>;
insertCollection.items.push({
id: mustGet(itemRefToId, item.ref),
boardId,
kind: item.kind,
options: stringifyForDb(optionsWithRefs),
advancedOptions: stringifyForDb(item.advancedOptions),
});
for (const layout of item.layouts) {
insertCollection.itemLayouts.push({
itemId: mustGet(itemRefToId, item.ref),
sectionId: mustGet(sectionRefToId, layout.sectionRef),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
xOffset: layout.x,
yOffset: layout.y,
width: layout.w,
height: layout.h,
});
}
for (const integrationRef of item.integrationRefs) {
const integrationId = integrationRefToId.get(integrationRef);
if (integrationId) {
insertCollection.integrationItems.push({
itemId: mustGet(itemRefToId, item.ref),
integrationId,
});
}
}
}
}
return boardsCreated;
};
export const importFullConfigAsync = async (
db: Database,
bundle: HomarrConfigBundle,
creatorId: string | null,
): Promise<ConfigImportReport> => {
const existing = await loadExistingEntitiesAsync(db);
const existingMembershipKeys = new Set(existing.groupMembers.map((gm) => `${gm.groupId}:${gm.userId}`));
const resolvedApps = resolveApps(bundle, existing.apps);
const resolvedIntegrations = resolveIntegrations(bundle, existing.integrations);
const resolvedBoardsResult = resolveBoards(bundle, existing.boards);
const resolvedSearchEngines = resolveSearchEngines(bundle, existing.searchEngines, resolvedIntegrations.refToId);
const refMaps: Record<string, Map<string, string>> = {
boards: resolvedBoardsResult.boardRefToId,
searchEngines: resolvedSearchEngines.refToId,
integrations: resolvedIntegrations.refToId,
users: new Map<string, string>(),
};
const resolvedUsers = resolveUsers(bundle, existing.users, refMaps);
refMaps.users = resolvedUsers.refToId;
const resolvedGroups = resolveGroups(bundle, existing.groups, existingMembershipKeys, refMaps);
const resolvedSettings = resolveServerSettings(bundle, existing.serverSettings);
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
"integrations",
"integrationSecrets",
"boards",
"layouts",
"sections",
"sectionLayouts",
"items",
"itemLayouts",
"integrationItems",
"searchEngines",
"users",
"groups",
"groupPermissions",
"groupMembers",
"boardGroupPermissions",
"integrationGroupPermissions",
]);
insertCollection.apps.push(...resolvedApps.rows);
insertCollection.integrations.push(...resolvedIntegrations.rows);
insertCollection.integrationSecrets.push(...resolvedIntegrations.secretRows);
insertCollection.searchEngines.push(...resolvedSearchEngines.rows);
insertCollection.users.push(...resolvedUsers.rows);
insertCollection.groups.push(...resolvedGroups.rows);
insertCollection.groupPermissions.push(...resolvedGroups.groupPermRows);
insertCollection.groupMembers.push(...resolvedGroups.memberRows);
insertCollection.boardGroupPermissions.push(...resolvedGroups.boardGroupPermRows);
insertCollection.integrationGroupPermissions.push(...resolvedGroups.integrationGroupPermRows);
const boardsCreated = buildBoardInserts(
bundle,
resolvedBoardsResult.boardsToCreate,
resolvedBoardsResult.boardRefToId,
resolvedApps.refToId,
resolvedIntegrations.refToId,
creatorId,
insertCollection,
);
await handleTransactionsAsync(db, {
async handleAsync(innerDb, dbSchema) {
await innerDb.transaction(async (trx) => {
await insertCollection.insertAllAsync(trx);
if (resolvedSettings.toInsert.length > 0) {
await trx.insert(dbSchema.serverSettings).values(resolvedSettings.toInsert as never);
}
for (const setting of resolvedSettings.toUpdate) {
await trx
.update(dbSchema.serverSettings)
.set({ value: setting.value })
.where(eq(dbSchema.serverSettings.settingKey, setting.key));
}
});
},
handleSync(innerDb) {
innerDb.transaction((trx) => {
insertCollection.insertAll(trx);
if (resolvedSettings.toInsert.length > 0) {
trx.insert(serverSettings).values(resolvedSettings.toInsert as never).run();
}
for (const setting of resolvedSettings.toUpdate) {
trx
.update(serverSettings)
.set({ value: setting.value })
.where(eq(serverSettings.settingKey, setting.key))
.run();
}
});
},
});
const warnings = [
...resolvedIntegrations.warnings,
...resolvedBoardsResult.warnings,
...resolvedSearchEngines.warnings,
...resolvedGroups.warnings,
];
return {
boards: boardsCreated,
apps: resolvedApps.rows.length,
integrations: resolvedIntegrations.rows.length,
groups: resolvedGroups.rows.length,
users: resolvedUsers.rows.length,
searchEngines: resolvedSearchEngines.rows.length,
warnings,
};
};
@@ -1,4 +0,0 @@
export { parseBundleJson } from "./parse-bundle";
export { prepareBoardImport, type PreparedBundleImport } from "./prepare-board";
export { importBoardBundleAsync, type BundleImportReport } from "./insert";
export { doAppsMatch } from "./match-apps";
@@ -1,79 +0,0 @@
import { handleTransactionsAsync } from "@homarr/db";
import type { Database } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import type { HomarrBundle } from "../schema";
import type { PreparedBundleImport } from "./prepare-board";
import { prepareBoardImport } from "./prepare-board";
export type BundleImportReport = {
created: {
apps: number;
items: number;
};
warnings: string[];
boardId: string;
};
export const importBoardBundleAsync = async (
db: Database,
bundle: HomarrBundle,
boardName: string,
creatorId: string,
existingApps: Awaited<ReturnType<typeof db.query.apps.findMany>>,
existingIntegrations: Awaited<ReturnType<typeof db.query.integrations.findMany>>,
integrationIdsWithAccess: string[],
hasAccessForAll: boolean,
): Promise<BundleImportReport> => {
const prepared = prepareBoardImport(
bundle,
boardName,
creatorId,
existingApps,
existingIntegrations,
integrationIdsWithAccess,
hasAccessForAll,
);
await insertPreparedBundleAsync(db, prepared);
return {
created: {
apps: prepared.createdAppsCount,
items: prepared.createdItemsCount,
},
warnings: prepared.warnings,
boardId: prepared.board.id,
};
};
const insertPreparedBundleAsync = async (db: Database, prepared: PreparedBundleImport) => {
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
"boards",
"layouts",
"sections",
"sectionLayouts",
"items",
"itemLayouts",
"integrationItems",
]);
insertCollection.apps.push(...prepared.apps);
insertCollection.boards.push(prepared.board);
insertCollection.layouts.push(...prepared.layouts);
insertCollection.sections.push(...prepared.sections);
insertCollection.sectionLayouts.push(...prepared.sectionLayouts);
insertCollection.items.push(...prepared.items);
insertCollection.itemLayouts.push(...prepared.itemLayouts);
insertCollection.integrationItems.push(...prepared.integrationItems);
await handleTransactionsAsync(db, {
async handleAsync(innerDb) {
await insertCollection.insertAllAsync(innerDb);
},
handleSync(innerDb) {
insertCollection.insertAll(innerDb);
},
});
};
@@ -1,39 +0,0 @@
import type { Database, InferSelectModel } from "@homarr/db";
import type { groupMembers, groups, searchEngines, serverSettings, users } from "@homarr/db/schema";
import { groupMembers as groupMembersTable, groups as groupsTable, searchEngines as searchEnginesTable, serverSettings as serverSettingsTable, users as usersTable } from "@homarr/db/schema";
export type ExistingEntities = {
apps: Awaited<ReturnType<Database["query"]["apps"]["findMany"]>>;
integrations: Awaited<ReturnType<Database["query"]["integrations"]["findMany"]>>;
boards: Awaited<ReturnType<Database["query"]["boards"]["findMany"]>>;
groups: InferSelectModel<typeof groups>[];
searchEngines: InferSelectModel<typeof searchEngines>[];
users: InferSelectModel<typeof users>[];
groupMembers: InferSelectModel<typeof groupMembers>[];
serverSettings: InferSelectModel<typeof serverSettings>[];
};
export const loadExistingEntitiesAsync = async (db: Database): Promise<ExistingEntities> => {
const [appsResult, integrationsResult, boardsResult, groupsResult, searchEnginesResult, usersResult, groupMembersResult, serverSettingsResult] =
await Promise.all([
db.query.apps.findMany(),
db.query.integrations.findMany(),
db.query.boards.findMany(),
db.select().from(groupsTable),
db.select().from(searchEnginesTable),
db.select().from(usersTable),
db.select().from(groupMembersTable),
db.select().from(serverSettingsTable),
]);
return {
apps: appsResult,
integrations: integrationsResult,
boards: boardsResult,
groups: groupsResult,
searchEngines: searchEnginesResult,
users: usersResult,
groupMembers: groupMembersResult,
serverSettings: serverSettingsResult,
};
};
@@ -1,14 +0,0 @@
import type { InferSelectModel } from "@homarr/db";
import type { apps } from "@homarr/db/schema";
export const doAppsMatch = (
app1: Omit<InferSelectModel<typeof apps>, "id">,
app2: Omit<InferSelectModel<typeof apps>, "id">,
) => {
return (
app1.name === app2.name &&
app1.iconUrl === app2.iconUrl &&
app1.description === app2.description &&
app1.href === app2.href
);
};
@@ -1,7 +0,0 @@
import type { HomarrBundle } from "../schema";
import { homarrBundleSchema } from "../schema";
export const parseBundleJson = (content: string): HomarrBundle => {
const parsed: unknown = JSON.parse(content);
return homarrBundleSchema.parse(parsed);
};
@@ -1,175 +0,0 @@
import SuperJSON from "superjson";
import type { Database } from "@homarr/db";
import type { IntegrationKind } from "@homarr/definitions";
import { assessBundleCompatibility } from "../config-bundle-compat";
import type { HomarrConfigBundle } from "../schema";
import type { ConfigEntityCounts, ConfigImportPreview } from "../types";
import { emptyCounts } from "../types";
import { loadExistingEntitiesAsync } from "./load-existing-entities";
import { doAppsMatch } from "./match-apps";
const emptyPreview = (
compatibility: ConfigImportPreview["compatibility"],
meta: ConfigImportPreview["meta"],
warnings: string[] = [],
): ConfigImportPreview => ({
compatibility,
meta,
toCreate: emptyCounts(),
toReuse: { apps: 0, integrations: 0, users: 0 },
toSkip: { boards: 0, groups: 0, searchEngines: 0 },
toUpdate: { serverSettings: 0 },
warnings,
});
const extractMetaFromUnknown = (parsed: unknown): ConfigImportPreview["meta"] => {
const obj = parsed as Record<string, unknown>;
return {
exportedAt: typeof obj.exportedAt === "string" ? obj.exportedAt : "",
homarrVersion: typeof obj.homarrVersion === "string" ? obj.homarrVersion : "",
bundleVersion: typeof obj.version === "string" ? obj.version : "",
};
};
const integrationMatches = (
bundleIntegration: HomarrConfigBundle["integrations"][number],
existing: { kind: IntegrationKind; name: string; url: string },
) =>
bundleIntegration.kind === existing.kind &&
bundleIntegration.name === existing.name &&
bundleIntegration.url === existing.url;
export const previewImportFullConfigAsync = async (
db: Database,
content: string,
currentHomarrVersion: string,
): Promise<ConfigImportPreview> => {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return emptyPreview(
{ status: "invalidStructure", bundleVersion: null, bundleHomarrVersion: null, currentHomarrVersion, issues: ["File is not valid JSON"] },
{ exportedAt: "", homarrVersion: "", bundleVersion: "" },
);
}
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
if (!bundle || compatibility.status !== "compatible") {
return emptyPreview(compatibility, extractMetaFromUnknown(parsed), compatibility.issues);
}
const existing = await loadExistingEntitiesAsync(db);
const existingSettingsByKey = new Map(
existing.serverSettings.map((setting) => [setting.settingKey, setting.value] as const),
);
const existingBoardNames = new Set(existing.boards.map((b) => b.name));
const existingGroupNames = new Set(existing.groups.map((g) => g.name));
const existingSearchEngineShorts = new Set(existing.searchEngines.map((se) => se.short));
const existingUserEmails = new Set(existing.users.filter((u) => u.email).map((u) => u.email!));
const toCreate = emptyCounts();
const warnings: string[] = [];
let reuseApps = 0;
let reuseIntegrations = 0;
let reuseUsers = 0;
let skipBoards = 0;
let skipGroups = 0;
let skipSearchEngines = 0;
let updateServerSettings = 0;
for (const bundleApp of bundle.apps) {
const matched = existing.apps.find((app) =>
doAppsMatch(app, {
name: bundleApp.name,
iconUrl: bundleApp.iconUrl,
description: bundleApp.description ?? null,
href: bundleApp.href,
pingUrl: bundleApp.pingUrl ?? null,
}),
);
if (matched) {
reuseApps += 1;
continue;
}
toCreate.apps += 1;
}
for (const bundleIntegration of bundle.integrations) {
const matched = existing.integrations.find((i) => integrationMatches(bundleIntegration, i));
if (matched) {
reuseIntegrations += 1;
continue;
}
toCreate.integrations += 1;
toCreate.secrets += bundleIntegration.secrets.length;
}
for (const bundleBoard of bundle.boards) {
if (existingBoardNames.has(bundleBoard.name)) {
skipBoards += 1;
warnings.push(`Board "${bundleBoard.name}" already exists and will be skipped`);
continue;
}
toCreate.boards += 1;
toCreate.widgets += bundleBoard.items.length;
toCreate.sections += bundleBoard.sections.length;
toCreate.layouts += bundleBoard.layouts.length;
}
for (const bundleGroup of bundle.groups) {
if (existingGroupNames.has(bundleGroup.name)) {
skipGroups += 1;
warnings.push(`Group "${bundleGroup.name}" already exists and will be skipped`);
continue;
}
toCreate.groups += 1;
}
for (const bundleSearchEngine of bundle.searchEngines) {
if (existingSearchEngineShorts.has(bundleSearchEngine.short)) {
skipSearchEngines += 1;
warnings.push(`Search engine "${bundleSearchEngine.name}" already exists and will be skipped`);
continue;
}
toCreate.searchEngines += 1;
}
for (const bundleUser of bundle.users ?? []) {
if (bundleUser.email && existingUserEmails.has(bundleUser.email)) {
reuseUsers += 1;
continue;
}
toCreate.users += 1;
}
for (const [settingKey, value] of Object.entries(bundle.serverSettings)) {
const serialized = SuperJSON.stringify(value);
const existingValue = existingSettingsByKey.get(settingKey);
if (existingValue === undefined) {
toCreate.serverSettings += 1;
continue;
}
if (existingValue !== serialized) {
updateServerSettings += 1;
}
}
return {
compatibility,
meta: {
exportedAt: bundle.exportedAt,
homarrVersion: bundle.homarrVersion,
bundleVersion: bundle.version,
},
toCreate,
toReuse: { apps: reuseApps, integrations: reuseIntegrations, users: reuseUsers },
toSkip: { boards: skipBoards, groups: skipGroups, searchEngines: skipSearchEngines },
toUpdate: { serverSettings: updateServerSettings },
warnings,
};
};
@@ -1,203 +0,0 @@
import { createId } from "@homarr/common";
import type { InferInsertModel, InferSelectModel } from "@homarr/db";
import type {
apps,
boards,
integrationItems,
integrations,
itemLayouts,
items,
layouts,
sectionLayouts,
sections,
} from "@homarr/db/schema";
import type { HomarrBundle, HomarrBundleBoard } from "../schema";
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
import { doAppsMatch } from "./match-apps";
export type PreparedBundleImport = {
apps: InferInsertModel<typeof apps>[];
board: InferInsertModel<typeof boards>;
layouts: InferInsertModel<typeof layouts>[];
sections: InferInsertModel<typeof sections>[];
sectionLayouts: InferInsertModel<typeof sectionLayouts>[];
items: InferInsertModel<typeof items>[];
itemLayouts: InferInsertModel<typeof itemLayouts>[];
integrationItems: InferInsertModel<typeof integrationItems>[];
warnings: string[];
createdAppsCount: number;
createdItemsCount: number;
};
const integrationMatches = (
bundleIntegration: HomarrBundle["integrations"][number],
existing: InferSelectModel<typeof integrations>,
) => {
return (
bundleIntegration.kind === existing.kind &&
bundleIntegration.name === existing.name &&
bundleIntegration.url === existing.url
);
};
export const prepareBoardImport = (
bundle: HomarrBundle,
boardName: string,
creatorId: string,
existingApps: InferSelectModel<typeof apps>[],
existingIntegrations: InferSelectModel<typeof integrations>[],
integrationIdsWithAccess: string[],
hasAccessForAll: boolean,
): PreparedBundleImport => {
const warnings: string[] = [];
if (bundle.boards.length > 1) {
warnings.push(`Bundle contains ${bundle.boards.length} boards; only the first board was imported`);
}
const sourceBoard = bundle.boards[0] as HomarrBundleBoard | undefined;
if (!sourceBoard) {
throw new Error("Bundle does not contain a board");
}
const boardId = createId();
const layoutRefToId = new Map(sourceBoard.layouts.map((layout) => [layout.ref, createId()]));
const sectionRefToId = new Map(sourceBoard.sections.map((section) => [section.ref, createId()]));
const itemRefToId = new Map(sourceBoard.items.map((item) => [item.ref, createId()]));
const appRefToId = new Map<string, string>();
const appsToInsert: InferInsertModel<typeof apps>[] = [];
let createdAppsCount = 0;
for (const bundleApp of bundle.apps) {
const existingApp = existingApps.find((existing) =>
doAppsMatch(existing, {
name: bundleApp.name,
iconUrl: bundleApp.iconUrl,
description: bundleApp.description ?? null,
href: bundleApp.href,
pingUrl: bundleApp.pingUrl ?? null,
}),
);
if (existingApp) {
appRefToId.set(bundleApp.ref, existingApp.id);
continue;
}
const newAppId = createId();
appRefToId.set(bundleApp.ref, newAppId);
appsToInsert.push({
id: newAppId,
name: bundleApp.name,
iconUrl: bundleApp.iconUrl,
href: bundleApp.href,
description: bundleApp.description ?? null,
pingUrl: bundleApp.pingUrl ?? null,
});
createdAppsCount += 1;
}
const integrationRefToId = new Map<string, string>();
for (const bundleIntegration of bundle.integrations) {
const existingIntegration = existingIntegrations.find((existing) =>
integrationMatches(bundleIntegration, existing),
);
if (!existingIntegration) {
warnings.push(
`Integration "${bundleIntegration.name}" (${bundleIntegration.kind}) was not found and linked widgets were skipped`,
);
continue;
}
const hasAccess = hasAccessForAll || integrationIdsWithAccess.includes(existingIntegration.id);
if (!hasAccess) {
warnings.push(`Integration "${bundleIntegration.name}" is not accessible and was skipped`);
continue;
}
integrationRefToId.set(bundleIntegration.ref, existingIntegration.id);
}
const board: PreparedBundleImport["board"] = buildBoardInsertRow(boardId, boardName, creatorId, sourceBoard.settings);
const layouts = sourceBoard.layouts.map((layout) => ({
id: mustGet(layoutRefToId, layout.ref),
boardId,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
}));
const sections = sourceBoard.sections.map((section) => ({
id: mustGet(sectionRefToId, section.ref),
boardId,
kind: section.kind,
name: section.name ?? null,
xOffset: section.xOffset ?? null,
yOffset: section.yOffset ?? null,
options: section.options ? stringifyForDb(section.options) : undefined,
}));
const sectionLayouts = sourceBoard.sections.flatMap((section) =>
(section.layouts ?? []).map((layout) => ({
sectionId: mustGet(sectionRefToId, section.ref),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef),
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
})),
);
const items = sourceBoard.items.map((item) => {
const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record<string, unknown>;
return {
id: mustGet(itemRefToId, item.ref),
boardId,
kind: item.kind,
options: stringifyForDb(optionsWithRefs),
advancedOptions: stringifyForDb(item.advancedOptions),
};
});
const itemLayouts = sourceBoard.items.flatMap((item) =>
item.layouts.map((layout) => ({
itemId: mustGet(itemRefToId, item.ref),
sectionId: mustGet(sectionRefToId, layout.sectionRef),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
xOffset: layout.x,
yOffset: layout.y,
width: layout.w,
height: layout.h,
})),
);
const integrationItems = sourceBoard.items.flatMap((item) =>
item.integrationRefs
.map((integrationRef) => integrationRefToId.get(integrationRef))
.filter((integrationId): integrationId is string => integrationId !== undefined)
.map((integrationId) => ({
itemId: mustGet(itemRefToId, item.ref),
integrationId,
})),
);
return {
apps: appsToInsert,
board,
layouts,
sections,
sectionLayouts,
items,
itemLayouts,
integrationItems,
warnings,
createdAppsCount,
createdItemsCount: items.length,
};
};
@@ -1,54 +0,0 @@
import { createId } from "@homarr/common";
export type EntityResolverDef<TBundle, TExisting extends { id: string }, TInsert> = {
getRef: (bundle: TBundle) => string;
match: (bundle: TBundle, existing: TExisting) => boolean;
toInsertRow: (bundle: TBundle, newId: string) => TInsert;
skipMessage?: (bundle: TBundle) => string;
};
export type ResolveResult<TInsert> = {
refToId: Map<string, string>;
rows: TInsert[];
warnings: string[];
};
export const resolveEntities = <TBundle, TExisting extends { id: string }, TInsert>(
bundleEntities: TBundle[],
existingEntities: TExisting[],
def: EntityResolverDef<TBundle, TExisting, TInsert>,
): ResolveResult<TInsert> => {
const refToId = new Map<string, string>();
const rows: TInsert[] = [];
const warnings: string[] = [];
for (const bundleEntity of bundleEntities) {
const existing = existingEntities.find((e) => def.match(bundleEntity, e));
if (existing) {
refToId.set(def.getRef(bundleEntity), existing.id);
if (def.skipMessage) warnings.push(def.skipMessage(bundleEntity));
continue;
}
const newId = createId();
refToId.set(def.getRef(bundleEntity), newId);
rows.push(def.toInsertRow(bundleEntity, newId));
}
return { refToId, rows, warnings };
};
export const groupByKey = <T, K extends string | number>(
items: T[],
getKey: (item: T) => K,
): Map<K, T[]> => {
const map = new Map<K, T[]>();
for (const item of items) {
const key = getKey(item);
const list = map.get(key) ?? [];
list.push(item);
map.set(key, list);
}
return map;
};
-199
View File
@@ -1,199 +0,0 @@
import { z } from "zod/v4";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
integrationKinds,
sectionKinds,
widgetKinds,
} from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import { zodEnumFromArray } from "@homarr/validation/enums";
const bundleVersionSchema = z.literal("1.0");
const bundleBoardSettingsSchema = z.object({
pageTitle: z.string().nullable().optional(),
metaTitle: z.string().nullable().optional(),
logoImageUrl: z.string().nullable().optional(),
faviconImageUrl: z.string().nullable().optional(),
backgroundImageUrl: z.string().nullable().optional(),
backgroundImageAttachment: z.enum(backgroundImageAttachments.values).optional(),
backgroundImageRepeat: z.enum(backgroundImageRepeats.values).optional(),
backgroundImageSize: z.enum(backgroundImageSizes.values).optional(),
primaryColor: z.string(),
secondaryColor: z.string(),
opacity: z.number(),
customCss: z.string().nullable().optional(),
iconColor: z.string().nullable().optional(),
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
disableStatus: z.boolean().optional(),
isPublic: z.boolean().optional(),
});
const bundleLayoutSchema = z.object({
ref: z.string(),
name: z.string(),
columnCount: z.number(),
breakpoint: z.number(),
});
const bundleSectionLayoutSchema = z.object({
layoutRef: z.string(),
parentSectionRef: z.string(),
xOffset: z.number(),
yOffset: z.number(),
width: z.number(),
height: z.number(),
});
const bundleSectionSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(sectionKinds),
name: z.string().nullable().optional(),
yOffset: z.number().nullable().optional(),
xOffset: z.number().nullable().optional(),
options: z.record(z.string(), z.unknown()).optional(),
layouts: z.array(bundleSectionLayoutSchema).optional(),
});
const bundleItemLayoutSchema = z.object({
layoutRef: z.string(),
sectionRef: z.string(),
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
});
const bundleItemSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown()),
advancedOptions: itemAdvancedOptionsSchema,
integrationRefs: z.array(z.string()),
appRef: z.string().optional(),
layouts: z.array(bundleItemLayoutSchema),
});
const bundleBoardSchema = z.object({
ref: z.string().optional(),
name: z.string(),
settings: bundleBoardSettingsSchema,
layouts: z.array(bundleLayoutSchema),
sections: z.array(bundleSectionSchema),
items: z.array(bundleItemSchema),
});
const bundleAppSchema = z.object({
ref: z.string(),
name: z.string(),
href: z.string().nullable(),
iconUrl: z.string(),
description: z.string().nullable().optional(),
pingUrl: z.string().nullable().optional(),
});
const bundleIntegrationSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(integrationKinds),
name: z.string(),
url: z.string(),
secretKinds: z.array(z.string()),
secrets: z.literal("REDACTED"),
});
export const homarrBundleSchema = z.object({
version: bundleVersionSchema,
exportedAt: z.string(),
homarrVersion: z.string(),
boards: z.array(bundleBoardSchema).min(1),
apps: z.array(bundleAppSchema),
integrations: z.array(bundleIntegrationSchema),
});
export type HomarrBundle = z.infer<typeof homarrBundleSchema>;
export type HomarrBundleBoard = z.infer<typeof bundleBoardSchema>;
export type HomarrBundleApp = z.infer<typeof bundleAppSchema>;
export type HomarrBundleIntegration = z.infer<typeof bundleIntegrationSchema>;
const configBundleSecretSchema = z.object({
kind: z.string(),
value: z.string(),
});
const configBundleIntegrationSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(integrationKinds),
name: z.string(),
url: z.string(),
secrets: z.array(configBundleSecretSchema),
});
const configBundleSearchEngineSchema = z.object({
ref: z.string(),
iconUrl: z.string(),
name: z.string(),
short: z.string(),
description: z.string().nullable().optional(),
urlTemplate: z.string().nullable().optional(),
type: z.string(),
integrationRef: z.string().nullable().optional(),
});
const configBundleUserSchema = z
.object({
ref: z.string(),
name: z.string().nullable(),
email: z.string().nullable(),
password: z.string().nullable(),
provider: z.string(),
homeBoardRef: z.string().nullable(),
mobileHomeBoardRef: z.string().nullable(),
defaultSearchEngineRef: z.string().nullable(),
colorScheme: z.string(),
firstDayOfWeek: z.number(),
openSearchInNewTab: z.boolean(),
ddgBangs: z.boolean(),
pingIconsEnabled: z.boolean(),
})
.catchall(z.unknown());
const configBundleGroupSchema = z.object({
ref: z.string(),
name: z.string(),
position: z.number(),
ownerRef: z.string().nullable().optional(),
homeBoardRef: z.string().nullable().optional(),
mobileHomeBoardRef: z.string().nullable().optional(),
permissions: z.array(z.string()),
boardPermissions: z.array(
z.object({ boardRef: z.string(), permission: z.string() }),
),
integrationPermissions: z.array(
z.object({ integrationRef: z.string(), permission: z.string() }),
),
memberRefs: z.array(z.string()).optional(),
});
export const homarrConfigBundleSchema = z.object({
version: z.literal("2.0"),
type: z.literal("full-config"),
exportedAt: z.string(),
homarrVersion: z.string(),
encryptionKey: z.string(),
boards: z.array(bundleBoardSchema),
apps: z.array(bundleAppSchema),
integrations: z.array(configBundleIntegrationSchema),
serverSettings: z.record(z.string(), z.unknown()),
searchEngines: z.array(configBundleSearchEngineSchema),
groups: z.array(configBundleGroupSchema),
users: z.array(configBundleUserSchema).optional(),
});
export type HomarrConfigBundle = z.infer<typeof homarrConfigBundleSchema>;
export type ConfigBundleIntegration = z.infer<typeof configBundleIntegrationSchema>;
export type ConfigBundleGroup = z.infer<typeof configBundleGroupSchema>;
export type ConfigBundleSearchEngine = z.infer<typeof configBundleSearchEngineSchema>;
export type ConfigBundleUser = z.infer<typeof configBundleUserSchema>;
-48
View File
@@ -1,48 +0,0 @@
import type { BundleCompatibility } from "./config-bundle-compat";
export const CONFIG_ENTITY_KEYS = [
"boards",
"apps",
"integrations",
"secrets",
"widgets",
"sections",
"layouts",
"searchEngines",
"groups",
"users",
"serverSettings",
] as const;
export type ConfigEntityKey = (typeof CONFIG_ENTITY_KEYS)[number];
export type ConfigEntityCounts = Record<ConfigEntityKey, number>;
export const emptyCounts = (): ConfigEntityCounts =>
Object.fromEntries(CONFIG_ENTITY_KEYS.map((key) => [key, 0])) as ConfigEntityCounts;
export type ConfigExportPreview = ConfigEntityCounts;
export type ConfigImportPreview = {
compatibility: BundleCompatibility;
meta: {
exportedAt: string;
homarrVersion: string;
bundleVersion: string;
};
toCreate: ConfigEntityCounts;
toReuse: {
apps: number;
integrations: number;
users: number;
};
toSkip: {
boards: number;
groups: number;
searchEngines: number;
};
toUpdate: {
serverSettings: number;
};
warnings: string[];
};
-74
View File
@@ -1,74 +0,0 @@
import SuperJSON from "superjson";
import type { InferInsertModel } from "@homarr/db";
import type { boards } from "@homarr/db/schema";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes, emptySuperJSON } from "@homarr/definitions";
import type { HomarrBundleBoard } from "./schema";
export const parseStoredValue = <T>(value: string | null | undefined, fallback: T): T => {
if (!value || value === emptySuperJSON) {
return fallback;
}
return SuperJSON.parse(value) as T;
};
export const stringifyForDb = (value: unknown): string => {
return SuperJSON.stringify(value);
};
export const mustGet = <T>(map: Map<string, T>, key: string): T => {
const value = map.get(key);
if (value === undefined) {
throw new Error(`Missing map entry for key "${key}"`);
}
return value;
};
const replaceMapValues = (value: unknown, map: Map<string, string>): unknown => {
if (typeof value === "string") {
return map.get(value) ?? value;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceMapValues(entry, map));
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, replaceMapValues(entry, map)]),
);
}
return value;
};
export const replaceAppIdsInValue = replaceMapValues;
export const replaceAppRefsInValue = replaceMapValues;
export const buildBoardInsertRow = (
boardId: string,
name: string,
creatorId: string | null,
settings: HomarrBundleBoard["settings"],
): InferInsertModel<typeof boards> => ({
id: boardId,
name,
creatorId,
isPublic: settings.isPublic ?? false,
pageTitle: settings.pageTitle ?? null,
metaTitle: settings.metaTitle ?? null,
logoImageUrl: settings.logoImageUrl ?? null,
faviconImageUrl: settings.faviconImageUrl ?? null,
backgroundImageUrl: settings.backgroundImageUrl ?? null,
backgroundImageAttachment: settings.backgroundImageAttachment ?? backgroundImageAttachments.defaultValue,
backgroundImageRepeat: settings.backgroundImageRepeat ?? backgroundImageRepeats.defaultValue,
backgroundImageSize: settings.backgroundImageSize ?? backgroundImageSizes.defaultValue,
primaryColor: settings.primaryColor,
secondaryColor: settings.secondaryColor,
opacity: settings.opacity,
customCss: settings.customCss ?? null,
iconColor: settings.iconColor ?? null,
itemRadius: settings.itemRadius,
disableStatus: settings.disableStatus ?? false,
});
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
-1
View File
@@ -26,7 +26,6 @@
"dependencies": {
"@drizzle-team/brocli": "catalog:",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
@@ -1,40 +0,0 @@
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { command, string } from "@drizzle-team/brocli";
import { exportBoardBundleAsync, bundleFilenameForBoard } from "@homarr/board-portability";
import { db } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { eq } from "@homarr/db";
export const boardsExport = command({
name: "export",
desc: "Export a board as a portable JSON bundle",
options: {
name: string("name").desc("Name of the board to export").required(),
output: string("output").alias("o").desc("Output file path (defaults to homarr-board-<name>.json)"),
},
// eslint-disable-next-line no-restricted-syntax
handler: async (opts) => {
const board = await db.query.boards.findFirst({
where: eq(boards.name, opts.name),
});
if (!board) {
console.error(`Board "${opts.name}" not found`);
process.exit(1);
}
const result = await exportBoardBundleAsync(db, board.id, "cli");
if (!result) {
console.error(`Failed to export board "${opts.name}"`);
process.exit(1);
}
const outputPath = resolve(opts.output ?? bundleFilenameForBoard(board.name));
writeFileSync(outputPath, result.content, "utf-8");
console.log(`Board exported to ${outputPath}`);
},
});
-21
View File
@@ -1,21 +0,0 @@
import { command } from "@drizzle-team/brocli";
import { db } from "@homarr/db";
export const boardsList = command({
name: "list",
desc: "List all boards (id, name, public)",
// eslint-disable-next-line no-restricted-syntax
handler: async () => {
const all = await db.query.boards.findMany();
if (all.length === 0) {
console.log("No boards found");
return;
}
console.log("ID\t\t\t\tName\t\tPublic");
for (const board of all) {
console.log(`${board.id}\t${board.name}\t\t${board.isPublic ? "yes" : "no"}`);
}
},
});
-10
View File
@@ -1,10 +0,0 @@
import { command } from "@drizzle-team/brocli";
import { boardsExport } from "./boards-export";
import { boardsList } from "./boards-list";
export const boardsRoot = command({
name: "boards",
desc: "Group of commands to manage boards",
subcommands: [boardsList, boardsExport],
});
+1 -2
View File
@@ -1,13 +1,12 @@
import { run } from "@drizzle-team/brocli";
import { boardsRoot } from "./commands/boards";
import { fixUsernames } from "./commands/fix-usernames";
import { integrationsRoot } from "./commands/integrations";
import { recreateAdmin } from "./commands/recreate-admin";
import { resetPassword } from "./commands/reset-password";
import { usersRoot } from "./commands/users";
const commands = [usersRoot, integrationsRoot, boardsRoot, resetPassword, fixUsernames, recreateAdmin];
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
void run(commands, {
name: "homarr-cli",
-1
View File
@@ -30,7 +30,6 @@
"@homarr/cron-jobs-core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
@@ -1,17 +0,0 @@
CREATE TABLE `docker_app_source` (
`host` varchar(512) NOT NULL,
`container_id` varchar(128) NOT NULL,
`external_id` varchar(512) NOT NULL,
`app_id` varchar(64) NOT NULL,
`board_id` varchar(64) NOT NULL,
`item_id` varchar(64),
`integration_id` varchar(64),
`updated_at` timestamp NOT NULL,
CONSTRAINT `docker_app_source_host_container_id_pk` PRIMARY KEY(`host`,`container_id`)
);
--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
File diff suppressed because it is too large. Load diff
@@ -281,13 +281,6 @@
"when": 1779199104477,
"tag": "0039_add_user_onboarding_tour_status",
"breakpoints": true
},
{
"idx": 40,
"version": "5",
"when": 1780490691902,
"tag": "0040_add_docker_app_source",
"breakpoints": true
}
]
}
}
@@ -1,17 +0,0 @@
CREATE TABLE "docker_app_source" (
"host" varchar(512) NOT NULL,
"container_id" varchar(128) NOT NULL,
"external_id" varchar(512) NOT NULL,
"app_id" varchar(64) NOT NULL,
"board_id" varchar(64) NOT NULL,
"item_id" varchar(64),
"integration_id" varchar(64),
"updated_at" timestamp NOT NULL,
CONSTRAINT "docker_app_source_host_container_id_pk" PRIMARY KEY("host","container_id")
);
--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "docker_app_source__external_id_idx" ON "docker_app_source" USING btree ("host","external_id");
File diff suppressed because it is too large. Load diff
@@ -43,13 +43,6 @@
"when": 1779199097716,
"tag": "0005_add_user_onboarding_tour_status",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1780490693344,
"tag": "0006_add_docker_app_source",
"breakpoints": true
}
]
}
}
@@ -1,17 +0,0 @@
CREATE TABLE `docker_app_source` (
`host` text NOT NULL,
`container_id` text NOT NULL,
`external_id` text NOT NULL,
`app_id` text NOT NULL,
`board_id` text NOT NULL,
`item_id` text,
`integration_id` text,
`updated_at` integer NOT NULL,
PRIMARY KEY(`host`, `container_id`),
FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
File diff suppressed because it is too large. Load diff
@@ -267,13 +267,6 @@
"when": 1779199046805,
"tag": "0037_add_user_onboarding_tour_status",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1780490686287,
"tag": "0038_add_docker_app_source",
"breakpoints": true
}
]
}
}
-1
View File
@@ -49,7 +49,6 @@ export const {
sectionLayouts,
trustedCertificateHostnames,
cronJobConfigurations,
dockerAppSources,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
-46
View File
@@ -504,33 +504,6 @@ export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = mysqlTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -795,22 +768,3 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
references: [boards.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
-46
View File
@@ -503,33 +503,6 @@ export const cronJobConfigurations = pgTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = pgTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -794,22 +767,3 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
references: [boards.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
-46
View File
@@ -489,33 +489,6 @@ export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
isEnabled: int({ mode: "boolean" }).default(true).notNull(),
});
export const dockerAppSources = sqliteTable(
"docker_app_source",
{
host: text().notNull(),
containerId: text().notNull(),
externalId: text().notNull(),
appId: text()
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: text().references(() => items.id, { onDelete: "set null" }),
integrationId: text().references(() => integrations.id, { onDelete: "set null" }),
updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -780,22 +753,3 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
references: [boards.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
@@ -1,57 +0,0 @@
import type { IntegrationKind } from "./integration";
import type { IntegrationSecretKind } from "./integration";
import type { WidgetKind } from "./widget";
export interface HomepageWidgetMapping {
integrationKind: IntegrationKind;
widgetKind: WidgetKind;
secretFieldMap: Record<string, IntegrationSecretKind>;
}
export const homepageWidgetMap: Record<string, HomepageWidgetMapping | null> = {
sonarr: { integrationKind: "sonarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
radarr: { integrationKind: "radarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
lidarr: { integrationKind: "lidarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
readarr: { integrationKind: "readarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
prowlarr: { integrationKind: "prowlarr", widgetKind: "indexerManager", secretFieldMap: { key: "apiKey" } },
bazarr: null,
jellyfin: { integrationKind: "jellyfin", widgetKind: "mediaServer", secretFieldMap: { key: "apiKey" } },
emby: { integrationKind: "emby", widgetKind: "mediaServer", secretFieldMap: { key: "apiKey" } },
plex: { integrationKind: "plex", widgetKind: "mediaServer", secretFieldMap: { key: "apiKey" } },
jellyseerr: {
integrationKind: "jellyseerr",
widgetKind: "mediaRequests-requestList",
secretFieldMap: { key: "apiKey" },
},
overseerr: {
integrationKind: "overseerr",
widgetKind: "mediaRequests-requestList",
secretFieldMap: { key: "apiKey" },
},
tautulli: null,
sabnzbd: { integrationKind: "sabNzbd", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
nzbget: { integrationKind: "nzbGet", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
deluge: { integrationKind: "deluge", widgetKind: "downloads", secretFieldMap: { password: "password" } },
transmission: {
integrationKind: "transmission",
widgetKind: "downloads",
secretFieldMap: { username: "username", password: "password" },
},
qbittorrent: {
integrationKind: "qBittorrent",
widgetKind: "downloads",
secretFieldMap: { username: "username", password: "password" },
},
pihole: { integrationKind: "piHole", widgetKind: "dnsHoleSummary", secretFieldMap: { key: "apiKey" } },
adguard: {
integrationKind: "adGuardHome",
widgetKind: "dnsHoleSummary",
secretFieldMap: { username: "username", password: "password" },
},
homeassistant: {
integrationKind: "homeAssistant",
widgetKind: "smartHome-entityState",
secretFieldMap: { key: "apiKey" },
},
proxmox: { integrationKind: "proxmox", widgetKind: "healthMonitoring", secretFieldMap: {} },
};
-1
View File
@@ -17,4 +17,3 @@ export * from "./emptysuperjson";
export * from "./hotkeys";
export * from "./docker-integration-match";
export * from "./integration-url-template";
export * from "./homepage-widget-map";
+1 -1
View File
@@ -364,7 +364,7 @@ export const integrationDefs = {
},
truenas: {
name: "TrueNAS",
secretKinds: [["username", "password"]],
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
+2 -6
View File
@@ -25,15 +25,11 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"dockerode": "catalog:",
"superjson": "catalog:"
"dockerode": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
"typescript": "catalog:"
}
}
@@ -1,469 +0,0 @@
import type { ContainerInfo } from "dockerode";
import { describe, expect, test } from "vitest";
import { dockerLabels, homepageLabels } from "../../labels";
import { parseContainerLabels } from "../parse-container-labels";
const createContainer = (labels: Record<string, string>, id = "container-abc123"): Pick<ContainerInfo, "Id" | "Labels"> => ({
Id: id,
Labels: labels,
});
describe("parseContainerLabels", () => {
describe("homarr native labels", () => {
test("parses all homarr labels into DiscoveredService", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "https://jellyfin.local",
[dockerLabels.icon]: "https://cdn.example/icon.png",
[dockerLabels.description]: "Media server",
[dockerLabels.ping]: "https://jellyfin.local/health",
[dockerLabels.id]: "jellyfin-1",
[dockerLabels.board]: "homelab",
[dockerLabels.integration]: "jellyfin",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Media",
name: "Jellyfin",
href: "https://jellyfin.local",
icon: "https://cdn.example/icon.png",
description: "Media server",
pingUrl: "https://jellyfin.local/health",
externalId: "jellyfin-1",
boardName: "homelab",
integrationKind: "jellyfin",
widgetKind: "mediaServer",
});
});
test("parses minimal required homarr labels (name, group, href)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Networking",
[dockerLabels.name]: "Pi-hole",
[dockerLabels.href]: "http://pihole:80/admin",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Networking",
name: "Pi-hole",
href: "http://pihole:80/admin",
externalId: "container-abc123",
});
});
test("uses container ID as externalId when homarr.id is absent", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
},
"sha256:deadbeef1234",
),
"socket",
);
expect(result?.externalId).toBe("sha256:deadbeef1234");
});
test("uses homarr.id as externalId when present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Plex",
[dockerLabels.href]: "http://plex:32400",
[dockerLabels.id]: "plex-server-main",
}),
"socket",
);
expect(result?.externalId).toBe("plex-server-main");
});
});
describe("homepage fallback labels", () => {
test("uses homepage labels when homarr.name is absent", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
[homepageLabels.icon]: "https://cdn.example/sonarr.png",
[homepageLabels.description]: "TV shows",
}),
"192.168.1.10:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "192.168.1.10:2375",
group: "Downloads",
name: "Sonarr",
href: "https://sonarr.local",
icon: "https://cdn.example/sonarr.png",
description: "TV shows",
externalId: "container-abc123",
});
});
test("does not use homepage fallback when readHomepageLabels is false", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
}),
"socket",
{ readHomepageLabels: false },
);
expect(result).toBeNull();
});
test("homepage labels have no ping, board, integration, or widget support", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Emby",
[homepageLabels.href]: "http://emby:8096",
}),
"socket",
);
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
});
});
describe("label priority and mixing", () => {
test("prefers homarr labels over homepage when both are present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Homarr Group",
[dockerLabels.name]: "Homarr Name",
[dockerLabels.href]: "https://homarr.local",
[homepageLabels.group]: "Homepage Group",
[homepageLabels.name]: "Homepage Name",
[homepageLabels.href]: "https://homepage.local",
}),
"socket",
);
expect(result?.group).toBe("Homarr Group");
expect(result?.name).toBe("Homarr Name");
expect(result?.href).toBe("https://homarr.local");
});
test("disables homepage fallback entirely when homarr.name is present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "MyApp",
[dockerLabels.href]: "http://myapp:3000",
[homepageLabels.group]: "FromHomepage",
}),
"socket",
);
expect(result).toBeNull();
});
test("uses homarr icon with homepage name/group/href when homarr.name absent but homarr.icon present", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Media",
[homepageLabels.name]: "Jellyfin",
[homepageLabels.href]: "http://jellyfin:8096",
[dockerLabels.icon]: "https://custom-icon.example/jf.png",
}),
"socket",
);
expect(result?.name).toBe("Jellyfin");
expect(result?.icon).toBe("https://custom-icon.example/jf.png");
});
});
describe("homarr.hide", () => {
test("skips container when homarr.hide is present with value 'true'", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "true",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is present with empty value", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips container when homarr.hide is any string (key presence is enough)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "anything",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("missing required labels", () => {
test("returns null when group is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.name]: "NoGroup",
[dockerLabels.href]: "https://nogroup.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when name is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.href]: "https://noname.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when href is missing", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "NoHref",
}),
"socket",
);
expect(result).toBeNull();
});
test("returns null when homepage labels are incomplete (missing group)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.name]: "Incomplete",
[homepageLabels.href]: "http://incomplete.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("whitespace handling", () => {
test("trims whitespace from all label values", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: " Media ",
[dockerLabels.name]: " Jellyfin ",
[dockerLabels.href]: " https://jellyfin.local ",
[dockerLabels.icon]: " https://icon.png ",
[dockerLabels.description]: " A media server ",
}),
"socket",
);
expect(result?.group).toBe("Media");
expect(result?.name).toBe("Jellyfin");
expect(result?.href).toBe("https://jellyfin.local");
expect(result?.icon).toBe("https://icon.png");
expect(result?.description).toBe("A media server");
});
test("treats whitespace-only values as empty (missing)", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: " ",
[dockerLabels.href]: "https://example.local",
}),
"socket",
);
expect(result).toBeNull();
});
});
describe("integration and widget kind validation", () => {
test("parses valid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Radarr",
[dockerLabels.href]: "http://radarr:7878",
[dockerLabels.integration]: "radarr",
}),
"socket",
);
expect(result?.integrationKind).toBe("radarr");
});
test("ignores invalid integration kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom App",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.integration]: "nonExistentIntegration",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
test("parses valid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "http://jellyfin:8096",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result?.widgetKind).toBe("mediaServer");
});
test("ignores invalid widget kind", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Custom",
[dockerLabels.href]: "http://custom:3000",
[dockerLabels.widget]: "totallyFakeWidget",
}),
"socket",
);
expect(result?.widgetKind).toBeUndefined();
});
test("handles empty integration kind gracefully", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "App",
[dockerLabels.href]: "http://app:3000",
[dockerLabels.integration]: "",
}),
"socket",
);
expect(result?.integrationKind).toBeUndefined();
});
});
describe("real-world scenarios", () => {
test("homepage-only service (common migration use case)", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Monitoring",
[homepageLabels.name]: "Grafana",
[homepageLabels.href]: "http://grafana:3000",
[homepageLabels.icon]: "grafana",
[homepageLabels.description]: "Metrics and dashboards",
}),
"tcp://192.168.1.50:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "tcp://192.168.1.50:2375",
group: "Monitoring",
name: "Grafana",
href: "http://grafana:3000",
icon: "grafana",
description: "Metrics and dashboards",
externalId: "container-abc123",
});
});
test("service targeting a specific board", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Infrastructure",
[dockerLabels.name]: "Traefik",
[dockerLabels.href]: "http://traefik:8080",
[dockerLabels.board]: "networking-board",
[dockerLabels.icon]: "https://cdn.example/traefik.svg",
}),
"socket",
);
expect(result?.boardName).toBe("networking-board");
});
test("service with all optional fields empty uses defaults", () => {
const result = parseContainerLabels(
createContainer(
{
[dockerLabels.group]: "Tools",
[dockerLabels.name]: "Portainer",
[dockerLabels.href]: "http://portainer:9000",
},
"abc123def456",
),
"socket",
);
expect(result?.icon).toBeUndefined();
expect(result?.description).toBeUndefined();
expect(result?.pingUrl).toBeUndefined();
expect(result?.boardName).toBeUndefined();
expect(result?.integrationKind).toBeUndefined();
expect(result?.widgetKind).toBeUndefined();
expect(result?.externalId).toBe("abc123def456");
});
test("container with no labels returns null", () => {
const result = parseContainerLabels(createContainer({}), "socket");
expect(result).toBeNull();
});
});
});
-8
View File
@@ -1,8 +0,0 @@
export type { DiscoveredService } from "./types";
export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
export { listDiscoveredContainersAsync } from "./list-discovered-containers";
export {
syncDiscoveredServicesAsync,
type SyncDiscoveredServicesOptions,
type SyncDiscoveredServicesResult,
} from "./sync-discovered-services";
@@ -1,45 +0,0 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { dockerLabels, homepageLabels } from "../labels";
import { DockerSingleton } from "../singleton";
import type { ParseContainerLabelsOptions } from "./parse-container-labels";
import { parseContainerLabels } from "./parse-container-labels";
import type { DiscoveredService } from "./types";
const logger = createLogger({ module: "dockerDiscovery" });
const hasDiscoveryLabels = (labels: Record<string, string>) =>
Object.values(dockerLabels).some((label) => label !== dockerLabels.hide && label in labels) ||
Object.values(homepageLabels).some((label) => label in labels);
export const listDiscoveredContainersAsync = async (
options: ParseContainerLabelsOptions = {},
): Promise<DiscoveredService[]> => {
const dockerInstances = DockerSingleton.getInstances();
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containers = await instance.listContainers({ all: true });
return containers
.filter((container) => hasDiscoveryLabels(container.Labels ?? {}))
.map((container) => parseContainerLabels(container, host, options))
.filter((service): service is DiscoveredService => service !== null);
}),
);
return results.flatMap((result, index) => {
if (result.status === "fulfilled") {
return result.value;
}
logger.warn(
new ErrorWithMetadata(
"Failed to list containers for discovery",
{ host: dockerInstances[index]?.host },
{ cause: result.reason },
),
);
return [];
});
};
@@ -1,110 +0,0 @@
import type { ContainerInfo } from "dockerode";
import { integrationKinds, widgetKinds } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { dockerLabels, homepageLabels } from "../labels";
import type { DiscoveredService } from "./types";
export interface ParseContainerLabelsOptions {
readHomepageLabels?: boolean;
}
const homarrLabelKeys = {
group: dockerLabels.group,
name: dockerLabels.name,
href: dockerLabels.href,
icon: dockerLabels.icon,
description: dockerLabels.description,
ping: dockerLabels.ping,
id: dockerLabels.id,
board: dockerLabels.board,
integration: dockerLabels.integration,
widget: dockerLabels.widget,
} as const;
const homepageLabelKeys = {
group: homepageLabels.group,
name: homepageLabels.name,
href: homepageLabels.href,
icon: homepageLabels.icon,
description: homepageLabels.description,
} as const;
const integrationKindSet = new Set<string>(integrationKinds);
const widgetKindSet = new Set<string>(widgetKinds);
const readLabel = (labels: Record<string, string>, key: string) => labels[key]?.trim();
const resolveLabelValue = (
labels: Record<string, string>,
homarrKey: string,
homepageKey: string,
useHomepageFallback: boolean,
) => readLabel(labels, homarrKey) ?? (useHomepageFallback ? readLabel(labels, homepageKey) : undefined);
export const parseContainerLabels = (
container: Pick<ContainerInfo, "Id" | "Labels">,
host: string,
options: ParseContainerLabelsOptions = {},
): DiscoveredService | null => {
const labels = container.Labels ?? {};
if (dockerLabels.hide in labels) {
return null;
}
const readHomepageLabels = options.readHomepageLabels ?? true;
const hasHomarrName = Boolean(readLabel(labels, dockerLabels.name));
const useHomepageFallback = readHomepageLabels && !hasHomarrName;
const name = resolveLabelValue(labels, homarrLabelKeys.name, homepageLabelKeys.name, useHomepageFallback);
const href = resolveLabelValue(labels, homarrLabelKeys.href, homepageLabelKeys.href, useHomepageFallback);
const group = resolveLabelValue(labels, homarrLabelKeys.group, homepageLabelKeys.group, useHomepageFallback);
if (!name || !href || !group) {
return null;
}
const icon =
readLabel(labels, homarrLabelKeys.icon) ??
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.icon) : undefined);
const description =
readLabel(labels, homarrLabelKeys.description) ??
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.description) : undefined);
const pingUrl = readLabel(labels, homarrLabelKeys.ping);
const boardName = readLabel(labels, homarrLabelKeys.board);
const externalId = readLabel(labels, homarrLabelKeys.id) ?? container.Id;
const integrationKind = parseIntegrationKind(readLabel(labels, homarrLabelKeys.integration));
const widgetKind = parseWidgetKind(readLabel(labels, homarrLabelKeys.widget));
return {
containerId: container.Id,
host,
group,
name,
href,
icon,
description,
pingUrl,
externalId,
boardName,
integrationKind,
widgetKind,
};
};
const parseIntegrationKind = (value: string | undefined): IntegrationKind | undefined => {
if (!value || !integrationKindSet.has(value)) {
return undefined;
}
return value as IntegrationKind;
};
const parseWidgetKind = (value: string | undefined): WidgetKind | undefined => {
if (!value || !widgetKindSet.has(value)) {
return undefined;
}
return value as WidgetKind;
};
@@ -1,451 +0,0 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { emptySuperJSON } from "@homarr/definitions";
import type { InferInsertModel, InferSelectModel } from "@homarr/db";
import { and, asc, db, eq, handleTransactionsAsync } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { apps, boards, dockerAppSources, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
import type { DiscoveredService } from "./types";
const logger = createLogger({ module: "dockerDiscoverySync" });
export interface SyncDiscoveredServicesOptions {
targetBoardName?: string | null;
enableStatusByDefault?: boolean;
forceDisableStatus?: boolean;
defaultItemWidth?: number;
defaultItemHeight?: number;
}
export interface SyncDiscoveredServicesResult {
created: number;
updated: number;
skipped: number;
}
interface SyncMetadata {
appId: string;
itemId?: string;
}
interface AppItemOptions {
appId: string;
openInNewTab: boolean;
showTitle: boolean;
descriptionDisplayMode: "hidden";
layout: "column";
pingEnabled: boolean;
}
type AppRow = InferSelectModel<typeof apps>;
type BoardWithRelations = NonNullable<Awaited<ReturnType<typeof loadBoardsWithRelationsAsync>>>[number];
type BoardItem = BoardWithRelations["items"][number];
const createSyncKey = (service: DiscoveredService) => `${service.host}/${service.containerId}`;
const appsMatchByHref = (app: Pick<AppRow, "href">, href: string) => app.href === href;
export const syncDiscoveredServicesAsync = async (
discoveredServices: DiscoveredService[],
options: SyncDiscoveredServicesOptions = {},
): Promise<SyncDiscoveredServicesResult> => {
const itemWidth = options.defaultItemWidth ?? 1;
const itemHeight = options.defaultItemHeight ?? 1;
const result: SyncDiscoveredServicesResult = { created: 0, updated: 0, skipped: 0 };
const syncMetadata = new Map<string, SyncMetadata>();
const existingApps = await db.query.apps.findMany();
const existingSources = await db.query.dockerAppSources.findMany();
const boardList = await loadBoardsWithRelationsAsync();
const appsToInsert: InferInsertModel<typeof apps>[] = [];
const appsToUpdate: { id: string; values: Partial<InferInsertModel<typeof apps>> }[] = [];
const sectionsToInsert: InferInsertModel<typeof sections>[] = [];
const itemsToInsert: InferInsertModel<typeof items>[] = [];
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
const pendingSectionsByBoard = new Map<string, InferInsertModel<typeof sections>[]>();
const sectionYOffsetByBoard = new Map<string, number>();
const itemYOffsetBySection = new Map<string, number>();
const pendingItemLayouts: InferInsertModel<typeof itemLayouts>[] = [];
for (const service of discoveredServices) {
const syncKey = createSyncKey(service);
if (syncMetadata.has(syncKey)) {
result.skipped += 1;
continue;
}
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
if (!board) {
logger.warn("No target board found for discovered service", { syncKey, boardName: service.boardName });
result.skipped += 1;
continue;
}
const firstLayout = getFirstLayout(board.layouts);
if (!firstLayout) {
logger.warn("Board has no layouts for discovered service", { boardId: board.id, syncKey });
result.skipped += 1;
continue;
}
const existingSource = existingSources.find(
(source) => source.host === service.host && source.externalId === service.externalId,
);
const existingApp = existingSource
? existingApps.find((app) => app.id === existingSource.appId)
: existingApps.find((app) => appsMatchByHref(app, service.href));
let appId: string;
let wasCreated = false;
let wasUpdated = false;
if (existingApp) {
appId = existingApp.id;
const updateValues = buildAppUpdateValues(service, existingApp);
if (updateValues) {
appsToUpdate.push({ id: appId, values: updateValues });
wasUpdated = true;
Object.assign(existingApp, updateValues);
}
} else {
appId = createId();
const newApp = buildAppInsert(service, appId);
appsToInsert.push(newApp);
existingApps.push(newApp as AppRow);
wasCreated = true;
}
const categorySection = findOrCreateCategorySection(
board,
service.group,
sectionsToInsert,
pendingSectionsByBoard,
sectionYOffsetByBoard,
);
const existingItem = findAppItemForApp(board.items, itemsToInsert, pendingItemLayouts, appId, categorySection.id);
if (!existingItem) {
const newItemId = createId();
const yOffset = getNextItemYOffset(
board,
categorySection.id,
firstLayout.id,
itemYOffsetBySection,
pendingItemLayouts,
itemHeight,
);
const newItemLayouts = board.layouts.map((layout) => ({
itemId: newItemId,
sectionId: categorySection.id,
layoutId: layout.id,
xOffset: 0,
yOffset,
width: itemWidth,
height: itemHeight,
}));
itemsToInsert.push({
id: newItemId,
boardId: board.id,
kind: "app",
options: SuperJSON.stringify(buildAppItemOptions(appId, service, options)),
advancedOptions: SuperJSON.stringify({
title: null,
customCssClasses: [],
borderColor: "",
}),
});
itemLayoutsToInsert.push(...newItemLayouts);
pendingItemLayouts.push(...newItemLayouts);
syncMetadata.set(syncKey, { appId, itemId: newItemId });
wasCreated = true;
} else {
syncMetadata.set(syncKey, { appId, itemId: existingItem.id });
}
if (wasCreated) {
result.created += 1;
} else if (wasUpdated) {
result.updated += 1;
} else {
result.skipped += 1;
}
}
for (const { id, values } of appsToUpdate) {
await db.update(apps).set(values).where(eq(apps.id, id));
}
const sourcesToUpsert: InferInsertModel<typeof dockerAppSources>[] = [];
for (const [syncKey, meta] of syncMetadata.entries()) {
const service = discoveredServices.find((svc) => createSyncKey(svc) === syncKey);
if (!service) continue;
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
if (!board) continue;
sourcesToUpsert.push({
host: service.host,
containerId: service.containerId,
externalId: service.externalId,
appId: meta.appId,
boardId: board.id,
itemId: meta.itemId ?? null,
});
}
if (
appsToInsert.length === 0 &&
sectionsToInsert.length === 0 &&
itemsToInsert.length === 0 &&
sourcesToUpsert.length === 0
) {
return result;
}
const insertCollection = createDbInsertCollectionForTransaction(["apps", "sections", "items", "itemLayouts"]);
insertCollection.apps.push(...appsToInsert);
insertCollection.sections.push(...sectionsToInsert);
insertCollection.items.push(...itemsToInsert);
insertCollection.itemLayouts.push(...itemLayoutsToInsert);
await handleTransactionsAsync(db, {
async handleAsync(innerDb) {
await insertCollection.insertAllAsync(innerDb);
const table = dockerAppSources as never;
for (const source of sourcesToUpsert) {
const existing = await innerDb.query.dockerAppSources.findFirst({
where: and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)),
});
if (existing) {
await innerDb
.update(table)
.set({ appId: source.appId, boardId: source.boardId, itemId: source.itemId, externalId: source.externalId })
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)));
} else {
await innerDb.insert(table).values(source as never);
}
}
},
handleSync(innerDb) {
insertCollection.insertAll(innerDb);
for (const source of sourcesToUpsert) {
try {
innerDb.insert(dockerAppSources).values(source).run();
} catch {
innerDb
.update(dockerAppSources)
.set({ appId: source.appId, boardId: source.boardId, itemId: source.itemId, externalId: source.externalId })
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)))
.run();
}
}
},
});
return result;
};
const loadBoardsWithRelationsAsync = async () =>
db.query.boards.findMany({
orderBy: asc(boards.name),
with: {
layouts: true,
sections: true,
items: {
with: {
layouts: true,
},
},
},
});
const resolveTargetBoard = (
boardList: BoardWithRelations[],
service: DiscoveredService,
defaultBoardName: string | null | undefined,
) => {
const boardName = service.boardName ?? defaultBoardName ?? undefined;
if (boardName) {
return boardList.find((board) => board.name === boardName) ?? boardList.at(0);
}
return boardList.at(0);
};
const getFirstLayout = (boardLayouts: InferSelectModel<typeof layouts>[]) =>
[...boardLayouts].sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint).at(0);
const buildAppInsert = (service: DiscoveredService, appId: string): InferInsertModel<typeof apps> => ({
id: appId,
name: service.name,
description: service.description ?? null,
iconUrl: service.icon ?? "",
href: service.href,
pingUrl: service.pingUrl ?? null,
});
const buildAppUpdateValues = (
service: DiscoveredService,
existingApp: AppRow,
): Partial<InferInsertModel<typeof apps>> | null => {
const values: Partial<InferInsertModel<typeof apps>> = {};
if (existingApp.name !== service.name) {
values.name = service.name;
}
if (existingApp.href !== service.href) {
values.href = service.href;
}
if (service.icon !== undefined && existingApp.iconUrl !== service.icon) {
values.iconUrl = service.icon;
}
if (service.description !== undefined && existingApp.description !== service.description) {
values.description = service.description;
}
if (service.pingUrl !== undefined && existingApp.pingUrl !== service.pingUrl) {
values.pingUrl = service.pingUrl;
}
return Object.keys(values).length > 0 ? values : null;
};
const buildAppItemOptions = (
appId: string,
service: DiscoveredService,
options: SyncDiscoveredServicesOptions,
): AppItemOptions => ({
appId,
openInNewTab: true,
showTitle: true,
descriptionDisplayMode: "hidden",
layout: "column",
pingEnabled: service.pingUrl
? true
: !(options.forceDisableStatus ?? false) && (options.enableStatusByDefault ?? true),
});
const findOrCreateCategorySection = (
board: BoardWithRelations,
groupName: string,
sectionsToInsert: InferInsertModel<typeof sections>[],
pendingSectionsByBoard: Map<string, InferInsertModel<typeof sections>[]>,
sectionYOffsetByBoard: Map<string, number>,
): InferInsertModel<typeof sections> & { id: string } => {
const pendingForBoard = pendingSectionsByBoard.get(board.id) ?? [];
const pendingMatch = pendingForBoard.find((section) => section.kind === "category" && section.name === groupName);
if (pendingMatch) {
return pendingMatch as InferInsertModel<typeof sections> & { id: string };
}
const existingSection = board.sections.find((section) => section.kind === "category" && section.name === groupName);
if (existingSection) {
return existingSection;
}
const categorySections = board.sections.filter((section) => section.kind === "category");
const pendingCategorySections = pendingForBoard.filter((section) => section.kind === "category");
const maxYOffset = Math.max(
0,
...categorySections.map((section) => section.yOffset ?? 0),
...pendingCategorySections.map((section) => section.yOffset ?? 0),
sectionYOffsetByBoard.get(board.id) ?? 0,
);
const yOffset = maxYOffset + 1;
sectionYOffsetByBoard.set(board.id, yOffset);
const newSection: InferInsertModel<typeof sections> = {
id: createId(),
boardId: board.id,
kind: "category",
xOffset: 0,
yOffset,
name: groupName,
options: emptySuperJSON,
};
sectionsToInsert.push(newSection);
board.sections.push(newSection as BoardWithRelations["sections"][number]);
pendingSectionsByBoard.set(board.id, [...pendingForBoard, newSection]);
return newSection as InferInsertModel<typeof sections> & { id: string };
};
const findAppItemForApp = (
boardItems: BoardItem[],
pendingItems: InferInsertModel<typeof items>[],
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
appId: string,
sectionId: string,
) => {
const dbMatch = boardItems.find((item) => itemMatchesAppInSection(item, appId, sectionId));
if (dbMatch) {
return dbMatch;
}
const pendingMatch = pendingItems.find((item) => {
if (item.kind !== "app") {
return false;
}
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options ?? emptySuperJSON);
if (itemOptions.appId !== appId) {
return false;
}
return pendingLayouts.some((layout) => layout.itemId === item.id && layout.sectionId === sectionId);
});
return pendingMatch ? { id: pendingMatch.id } : undefined;
};
const itemMatchesAppInSection = (item: BoardItem, appId: string, sectionId: string) => {
if (item.kind !== "app") {
return false;
}
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options);
if (itemOptions.appId !== appId) {
return false;
}
return item.layouts.some((layout) => layout.sectionId === sectionId);
};
const getNextItemYOffset = (
board: BoardWithRelations,
sectionId: string,
layoutId: string,
itemYOffsetBySection: Map<string, number>,
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
itemHeight: number,
) => {
const sectionKey = `${board.id}/${sectionId}/${layoutId}`;
const trackedYOffset = itemYOffsetBySection.get(sectionKey);
if (trackedYOffset !== undefined) {
const nextYOffset = trackedYOffset + itemHeight;
itemYOffsetBySection.set(sectionKey, nextYOffset);
return trackedYOffset;
}
const dbLayouts = board.items.flatMap((item) =>
item.layouts.filter((layout) => layout.sectionId === sectionId && layout.layoutId === layoutId),
);
const pendingSectionLayouts = pendingLayouts.filter(
(layout) => layout.sectionId === sectionId && layout.layoutId === layoutId,
);
const maxYOffset = [...dbLayouts, ...pendingSectionLayouts].reduce(
(max, layout) => Math.max(max, layout.yOffset + layout.height),
0,
);
itemYOffsetBySection.set(sectionKey, maxYOffset + itemHeight);
return maxYOffset;
};
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more