Compare commits

..
Author SHA1 Message Date
Thomas Camlong f6a598b774 feat: config portability with generic entity resolver framework
Full configuration export/import system for Homarr with excellent DX:
- Export/import boards, apps, integrations (with re-encrypted secrets),
  users (with bcrypt hashes), groups (with permissions and memberships),
  search engines, and server settings
- Homepage services.yaml import with widget-type mapping
- Generic resolveEntities() utility eliminates per-entity boilerplate
- Declarative entity-fields.ts: adding a new user preference requires
  only one line change (no export/import/schema edits needed)
- User schema uses .catchall(z.unknown()) for forward-compatible prefs
- groupByKey() utility replaces manual Map-building
- CONFIG_ENTITY_KEYS as single source of truth for entity metadata
- Reuse existing entities (apps by name+icon+href, integrations by
  kind+name+url, users by email, groups by name, boards by name)
- Skip duplicates with warnings rather than failing
- Onboarding flow skips user creation step when config includes users
- Reuse existing admin group during onboarding after config import
2026-06-06 16:22:59 +02:00
Thomas Camlong 1f7b7f0516 Merge branch 'dev' into feat/config-portability
# Conflicts:
#	apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx
#	apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx
#	packages/api/src/router/docker/docker-router.ts
#	packages/api/src/router/test/docker/docker-router.spec.ts
#	packages/db/migrations/mysql/meta/0039_snapshot.json
#	packages/db/migrations/mysql/meta/_journal.json
#	packages/db/migrations/postgresql/meta/0005_snapshot.json
#	packages/db/migrations/postgresql/meta/_journal.json
#	packages/db/migrations/sqlite/meta/0037_snapshot.json
#	packages/db/migrations/sqlite/meta/_journal.json
2026-06-06 09:32:41 +02:00
Thomas Camlong fb8721bd4e feat: add config portability — full export/import, homepage import, docker label discovery
Add a dedicated Import/Export tool page with full configuration export/import
(v2.0 bundle with encrypted secrets), Homepage services.yaml import with env
variable resolution, and manual Docker label discovery (moved from cron job).

Support importing Homarr JSON during onboarding alongside legacy zip import.

New @homarr/board-portability package handles schema, serialization, compat
checks, and import planning. Shared preview UI reused across admin and onboarding.

Fix xterm.js crash by removing deprecated @xterm/addon-canvas (incompatible
with xterm v6). Fix Homepage env variable i18n and React 19 currentTarget bug.

See docs/config-portability.md for full changelog.
2026-06-04 10:56:42 +02:00
137 changed files with 15313 additions and 4080 deletions

No files matched your search

+8
View File
@@ -1,4 +1,5 @@
Dockerfile
Dockerfile.dev
.dockerignore
node_modules
**/node_modules
@@ -14,3 +15,10 @@ apps/nextjs/.next
.env
.env.*
!.env.example
.docker-data
.docker-cache
coverage
.cursor
agent-transcripts
**/*.spec.ts
**/*.test.ts
+4
View File
@@ -72,3 +72,7 @@ en.d.json.ts
# personal agent config
AGENTS.md
.opencode
# local docker dev
.docker-data/
.docker-cache/
+1 -16
View File
@@ -5,21 +5,9 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const homarrVersion = (() => {
for (const candidate of ["../../package.json", "./package.json"]) {
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
if (pkg.name === "homarr") return pkg.version as string;
} catch {}
}
return "unknown";
})();
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin({
experimental: {
@@ -29,9 +17,6 @@ const withNextIntl = createNextIntlPlugin({
});
const nextConfig: NextConfig = {
env: {
HOMARR_VERSION: homarrVersion,
},
output: "standalone",
reactStrictMode: true,
// react compiler breaks mantine-react-table, so disabled for now
@@ -42,7 +27,7 @@ const nextConfig: NextConfig = {
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
+1 -8
View File
@@ -22,6 +22,7 @@
"@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",
@@ -64,7 +65,6 @@
"@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:",
@@ -72,13 +72,9 @@
"clsx": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"adm-zip": "catalog:",
"better-sqlite3": "catalog:",
"isomorphic-dompurify": "catalog:",
"jszip": "catalog:",
"jotai": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
@@ -89,7 +85,6 @@
"react-error-boundary": "catalog:",
"react-simple-code-editor": "catalog:",
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"swagger-ui-react": "catalog:",
"use-deep-compare-effect": "catalog:",
@@ -98,8 +93,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "catalog:",
"@types/better-sqlite3": "catalog:",
"@types/chroma-js": "catalog:",
"@types/node": "catalog:",
"@types/prismjs": "catalog:",
Binary file not shown.
@@ -6,11 +6,14 @@ 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");
};
@@ -0,0 +1,116 @@
"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,8 +11,20 @@ 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>(
@@ -0,0 +1,30 @@
"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,17 +1,14 @@
import { Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { IconFileImport, IconJson, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitSqliteRestore } from "./sqlite-restore";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
return (
<InitStepCard>
@@ -26,11 +23,18 @@ 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>
{isSqlite && <InitSqliteRestore />}
<InitStartButton
preferredStep="import"
importMode="config"
icon={<IconJson color={getMantineColor("blue", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importConfig")}
</InitStartButton>
</Stack>
</InitStepCard>
);
@@ -7,15 +7,28 @@ 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 }: PropsWithChildren<InitStartButtonProps>) => {
export const InitStartButton = ({
preferredStep,
icon,
children,
importMode,
}: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
if (importMode) {
setInitImportMode(importMode);
} else {
clearInitImportMode();
}
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
@@ -1,43 +0,0 @@
"use client";
import { useState } from "react";
import { Button, Collapse, Stack, Text } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const InitSqliteRestore = () => {
const t = useScopedI18n("init.step.start.action");
const [expanded, setExpanded] = useState(false);
if (expanded) {
return (
<Stack gap="sm">
<Button
variant="subtle"
size="xs"
onClick={() => setExpanded(false)}
>
{t("backToOptions")}
</Button>
<Text size="sm" c="dimmed">
{t("restoreSqliteDescription")}
</Text>
<DatabaseRestoreFlow variant="standalone" />
</Stack>
);
}
return (
<Button
onClick={() => setExpanded(true)}
variant="default"
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
>
{t("restoreSqlite")}
</Button>
);
};
@@ -2,14 +2,14 @@
import { useCallback } from "react";
import { Menu } from "@mantine/core";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDeviceMobile, IconDownload, 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 } from "@homarr/modals-collection";
import { DuplicateBoardModal, ExportBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -28,7 +28,10 @@ interface BoardCardMenuDropdownProps {
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action");
const t = useScopedI18n("management.page.board.action" as never) as unknown as (
key: string,
values?: Record<string, unknown>,
) => string;
const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
@@ -36,6 +39,7 @@ 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 () => {
@@ -87,6 +91,15 @@ 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} />}>
@@ -95,6 +108,9 @@ 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();
const t = useI18n() as unknown as (key: string) => string;
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
@@ -29,7 +29,6 @@ import {
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
@@ -157,10 +156,10 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.backup"),
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
icon: IconDatabaseExport,
href: "/manage/tools/backup",
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
href: "/manage/tools/import-export",
hidden: !session?.user.permissions.includes("admin"),
},
],
},
@@ -0,0 +1,58 @@
"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,12 +5,15 @@ 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";
@@ -59,6 +62,12 @@ 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>
</>
);
@@ -1,87 +0,0 @@
"use client";
import { useState } from "react";
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
export const BackupExportCard = () => {
const t = useScopedI18n("management.page.tool.backup.export");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const handleExport = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/backup/export");
if (!response.ok) {
const errorMessage = await response.json().then(
(data) => data.error,
() => `Server returned ${response.status}`,
);
throw new Error(errorMessage ?? t("error"));
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
setShowConfirm(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("error"));
} finally {
setLoading(false);
}
};
return (
<Card withBorder>
<Stack gap="sm">
<Group gap="sm">
<IconDatabaseExport size={24} />
<Title order={4}>{t("title")}</Title>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{showConfirm ? (
<DangerousActionConfirmation
title={t("confirm.title")}
warningTitle={t("confirm.warningTitle")}
warningBody={t("confirm.warningBody")}
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
submitLabel={t("confirm.submit")}
submitIcon={<IconDownload size={16} />}
cancelLabel={t("confirm.cancel")}
onConfirm={handleExport}
onCancel={() => setShowConfirm(false)}
disabled={loading}
color="orange"
/>
) : (
<Group>
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
{t("button")}
</Button>
</Group>
)}
</Stack>
</Card>
);
};
@@ -1,27 +0,0 @@
"use client";
import { Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const BackupImportCard = () => {
const t = useScopedI18n("management.page.tool.backup.import");
return (
<Card withBorder>
<Stack gap="md">
<Group gap="sm">
<IconDatabaseImport size={24} />
<Title order={4}>{t("title")}</Title>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<DatabaseRestoreFlow />
</Stack>
</Card>
);
};
@@ -1,40 +0,0 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
export async function generateMetadata() {
const session = await auth();
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
return {};
}
const t = await getScopedI18n("management.page.tool.backup");
return {
title: createMetaTitle(t("metaTitle")),
};
}
export default async function BackupPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
notFound();
}
const t = await getScopedI18n("management.page.tool.backup");
return (
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
);
}
@@ -2,7 +2,6 @@
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";
@@ -45,34 +44,34 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
return () => undefined;
}
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
const fitAddon = new FitAddon();
const terminal = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 14,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
isTerminalReadyRef.current = true;
const terminal = terminalRef.current;
pendingLogsRef.current.forEach((data) => terminal.write(data));
pendingLogsRef.current = [];
terminal.refresh(0, terminal.rows - 1);
setTimeout(() => {
const fitAddon = new FitAddon();
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
const fitTimeout = setTimeout(() => fitAddon.fit());
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(ref.current);
return () => {
clearTimeout(fitTimeout);
resizeObserver.disconnect();
isTerminalReadyRef.current = false;
pendingLogsRef.current = [];
canvasAddon.dispose();
terminalRef.current?.dispose();
fitAddon.dispose();
terminal.dispose();
terminalRef.current = null;
};
}, [writeToTerminal]);
@@ -0,0 +1,124 @@
"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>
);
};
@@ -0,0 +1,138 @@
"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>
);
};
@@ -0,0 +1,126 @@
"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>
);
};
@@ -0,0 +1,181 @@
"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>
);
};
@@ -0,0 +1,34 @@
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,7 +2,6 @@
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";
@@ -15,8 +14,8 @@ export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels, fontSize } = useLogContext();
const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
clientApi.log.subscribe.useSubscription(
{
@@ -38,28 +37,31 @@ export const TerminalComponent = () => {
return () => undefined;
}
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
const fitAddon = new FitAddon();
const terminal = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
setTimeout(() => {
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
const fitTerminal = () => fitAddon.fit();
const fitTimeout = setTimeout(fitTerminal);
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(ref.current);
return () => {
canvasAddon.dispose();
terminalRef.current?.dispose();
clearTimeout(fitTimeout);
resizeObserver.disconnect();
fitAddon.dispose();
terminal.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
};
}, []);
@@ -1,75 +0,0 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
export async function GET() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (dbEnv.DRIVER !== "better-sqlite3") {
return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 });
}
const dbPath = dbEnv.URL as string | undefined;
if (!dbPath || !fs.existsSync(dbPath)) {
return NextResponse.json({ error: "Database file not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite");
let sourceDb: InstanceType<typeof Database> | null = null;
try {
sourceDb = new Database(dbPath, { readonly: true });
sourceDb.pragma("wal_checkpoint(TRUNCATE)");
sourceDb.exec(`VACUUM INTO '${tempPath}'`);
sourceDb.close();
sourceDb = null;
const dbBuffer = fs.readFileSync(tempPath);
const metadata = {
homarrVersion: process.env.HOMARR_VERSION ?? "unknown",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: env.SECRET_ENCRYPTION_KEY,
};
const zip = new AdmZip();
zip.addFile("db.sqlite", dbBuffer);
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2)));
const zipBuffer = zip.toBuffer();
const date = new Date().toISOString().split("T")[0];
const filename = `homarr-backup-${date}.zip`;
return new NextResponse(zipBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": zipBuffer.length.toString(),
},
});
} catch (error) {
console.error("[backup/export] Export failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 });
} finally {
sourceDb?.close();
try {
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
} catch (cleanupErr) {
console.error("[backup/export] Failed to clean up temp file:", cleanupErr);
}
}
}
@@ -1,194 +0,0 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { db } from "@homarr/db";
import { findMigrationsFolder } from "../shared";
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
const ALGORITHM = "aes-256-cbc";
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
if (importedKeyHex === currentKeyHex) return;
if (!HEX_KEY_REGEX.test(importedKeyHex)) {
throw new Error("Imported encryption key is not a valid 64-character hex string");
}
const oldKey = Buffer.from(importedKeyHex, "hex");
const newKey = Buffer.from(currentKeyHex, "hex");
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
integration_id: string;
kind: string;
value: string;
}[];
if (rows.length === 0) return;
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
const transaction = tempDb.transaction(() => {
for (const row of rows) {
const parts = row.value.split(".");
if (parts.length !== 2) {
throw new Error(
`Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`,
);
}
const [data, dataIv] = parts as [string, string];
try {
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
const newIv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv);
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
updateStmt.run(newValue, row.integration_id, row.kind);
} catch {
throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`);
}
}
});
transaction();
};
const isOnboardingActiveAsync = async (): Promise<boolean> => {
const onboardingRow = await db.query.onboarding.findFirst();
if (!onboardingRow) return false;
return onboardingRow.step === "start";
};
export async function POST(req: Request) {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
if (!isAdmin && !isOnboarding) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (dbEnv.DRIVER !== "better-sqlite3") {
return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 });
}
const dbPath = dbEnv.URL as string | undefined;
if (!dbPath) {
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
}
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
let zip: AdmZip;
try {
zip = new AdmZip(Buffer.from(arrayBuffer));
} catch {
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
}
const entries = zip.getEntries().map((e) => e.entryName);
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
if (missingEntries.length > 0) {
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
}
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
let tempDb: InstanceType<typeof Database> | null = null;
try {
const dbEntry = zip.getEntry("db.sqlite")!;
fs.writeFileSync(tempPath, dbEntry.getData());
tempDb = new Database(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
migrate(drizzleDb, { migrationsFolder });
const metadataEntry = zip.getEntry("metadata.json")!;
const metadata = JSON.parse(metadataEntry.getData().toString());
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
const secretCount = (
tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }
).count;
if (secretCount > 0 && !importedKey) {
throw new Error(
"Backup contains integration secrets but no encryption key. " +
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
);
}
if (importedKey) {
reEncryptSecrets(tempDb, importedKey);
}
tempDb.close();
tempDb = null;
fs.renameSync(tempPath, dbPath);
for (const suffix of ["-wal", "-shm"] as const) {
try {
const sidecar = `${dbPath}${suffix}`;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
} catch (cleanupErr) {
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
}
}
setTimeout(() => {
console.log("Database restored, restarting server...");
process.exit(0);
}, 500);
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
} catch (error) {
console.error("[backup/import] Restore failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
} finally {
tempDb?.close();
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// temp file may have been renamed already
}
}
}
}
@@ -1,63 +0,0 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { findMigrationsFolder } from "../shared";
interface JournalEntry {
idx: number;
version: string;
when: number;
tag: string;
breakpoints: boolean;
}
interface MigrationFile {
idx: number;
tag: string;
sql: string;
when: number;
}
export async function GET() {
try {
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
if (!fs.existsSync(journalPath)) {
return NextResponse.json({ error: "Migration journal not found" }, { status: 500 });
}
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] };
const migrations: MigrationFile[] = [];
for (const entry of journal.entries) {
const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`);
if (!fs.existsSync(sqlPath)) continue;
migrations.push({
idx: entry.idx,
tag: entry.tag,
sql: fs.readFileSync(sqlPath, "utf-8"),
when: entry.when,
});
}
return NextResponse.json(
{ total: migrations.length, migrations },
{
headers: {
"Cache-Control": "public, max-age=3600",
},
},
);
} catch (error) {
console.error("[backup/migrations] Failed to read migrations:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
}
}
-12
View File
@@ -1,12 +0,0 @@
import fs from "fs";
import path from "path";
const migrationsFolderCandidates = [
path.join(process.cwd(), "db/migrations/sqlite"),
path.join(process.cwd(), "packages/db/migrations/sqlite"),
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
export const findMigrationsFolder = () =>
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
@@ -1,30 +0,0 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
const wasmCandidates = [
path.join(process.cwd(), "public/sql-wasm.wasm"),
path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"),
path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"),
path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"),
];
let cachedWasm: Buffer | null = null;
export async function GET() {
if (!cachedWasm) {
const found = wasmCandidates.find((p) => fs.existsSync(p));
if (!found) {
return NextResponse.json({ error: "WASM binary not found" }, { status: 500 });
}
cachedWasm = fs.readFileSync(found);
}
return new NextResponse(cachedWasm, {
headers: {
"Content-Type": "application/wasm",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
@@ -1,308 +0,0 @@
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
const ALGORITHM = "aes-256-cbc";
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
const key = Buffer.from(keyHex, "hex");
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
};
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
const key = Buffer.from(keyHex, "hex");
const [data, dataIv] = value.split(".") as [string, string];
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
};
const createTestDb = (dbPath: string) => {
const sqlite = new Database(dbPath);
const db = drizzle(sqlite, { casing: DB_CASING });
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
return { db, sqlite };
};
describe("SQLite backup", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("export ZIP structure", () => {
it("should create a ZIP containing all required entries", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const backupPath = path.join(tmpDir, "db.backup.sqlite");
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
sqlite.close();
const dbBuffer = fs.readFileSync(backupPath);
const metadata = {
homarrVersion: "test",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: generateHexKey(),
};
const zip = new AdmZip();
zip.addFile("db.sqlite", dbBuffer);
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
const zipPath = path.join(tmpDir, "backup.zip");
zip.writeZip(zipPath);
const readBack = new AdmZip(zipPath);
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
const dbEntry = readBack.getEntry("db.sqlite");
expect(dbEntry).toBeDefined();
expect(dbEntry!.header.size).toBeGreaterThan(0);
});
it("should produce a valid SQLite database in the ZIP", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const backupPath = path.join(tmpDir, "db.backup.sqlite");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
sqlite.close();
const backupDb = new Database(backupPath, { readonly: true });
const tables = backupDb
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as { name: string }[];
expect(tables.map((t) => t.name)).toContain("board");
expect(tables.map((t) => t.name)).toContain("user");
expect(tables.map((t) => t.name)).toContain("integrationSecret");
backupDb.close();
});
});
describe("import ZIP validation", () => {
it("should detect missing required entries", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toEqual(["metadata.json"]);
});
it("should accept valid backup ZIP with all entries", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toHaveLength(0);
});
it("should accept legacy backup ZIP with encryption-key.txt", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toHaveLength(0);
});
});
describe("secret re-encryption", () => {
it("should re-encrypt secrets from old key to new key", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const oldKey = generateHexKey();
const newKey = generateHexKey();
const plaintext = "my-secret-password";
const encryptedValue = encryptWithKey(plaintext, oldKey);
sqlite.exec(`
INSERT INTO "integration" ("id", "name", "url", "kind")
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
`);
sqlite.exec(`
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
`);
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
expect(decryptedBefore).toBe(plaintext);
const oldKeyBuf = Buffer.from(oldKey, "hex");
const newKeyBuf = Buffer.from(newKey, "hex");
const rows = sqlite
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
.all() as { integration_id: string; kind: string; value: string }[];
const updateStmt = sqlite.prepare(
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
);
for (const row of rows) {
const [data, dataIv] = row.value.split(".") as [string, string];
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
const newIv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
updateStmt.run(newValue, row.integration_id, row.kind);
}
const updatedRow = sqlite
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
.get("int-1") as { value: string };
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
sqlite.close();
});
it("should skip re-encryption when keys match", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const sameKey = generateHexKey();
const plaintext = "my-secret";
const encryptedValue = encryptWithKey(plaintext, sameKey);
sqlite.exec(`
INSERT INTO "integration" ("id", "name", "url", "kind")
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
`);
sqlite.exec(`
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
`);
const rowBefore = sqlite
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
.get("int-1") as { value: string };
expect(rowBefore.value).toBe(encryptedValue);
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
expect(decrypted).toBe(plaintext);
sqlite.close();
});
});
describe("VACUUM INTO consistency", () => {
it("should produce a consistent copy via VACUUM INTO", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
sqlite.exec(`
INSERT INTO "app" ("id", "name", "icon_url")
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
`);
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
const backupDb = new Database(backupPath, { readonly: true });
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
expect(app).toBeDefined();
expect(app!.name).toBe("TestApp");
backupDb.close();
sqlite.close();
});
});
describe("migration on imported DB", () => {
it("should successfully migrate an imported database", () => {
const sourcePath = path.join(tmpDir, "source.sqlite");
const { sqlite: sourceDb } = createTestDb(sourcePath);
sourceDb.exec(`
INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status")
VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
`);
sourceDb.close();
const importPath = path.join(tmpDir, "import.sqlite");
fs.copyFileSync(sourcePath, importPath);
const importDb = new Database(importPath);
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
expect(board).toBeDefined();
expect(board!.name).toBe("TestBoard");
importDb.close();
});
});
describe("encryption key validation", () => {
it("should reject invalid hex keys", () => {
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
for (const key of invalidKeys) {
expect(hexKeyRegex.test(key)).toBe(false);
}
});
it("should accept valid 64-char hex keys", () => {
const validKey = generateHexKey();
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
expect(hexKeyRegex.test(validKey)).toBe(true);
});
});
});
@@ -1,114 +0,0 @@
"use client";
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { BackupAnalysis } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
interface BackupPreviewPanelProps {
analysis: BackupAnalysis;
}
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.preview");
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
const pendingCount = analysis.migrations.pending.length;
const hasPendingMigrations = pendingCount > 0;
return (
<Stack gap="md">
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Group gap="sm">
<ThemeIcon variant="light" color="blue" size="lg" radius="xl">
<IconDatabase size={18} />
</ThemeIcon>
<div>
<Title order={5}>{t("title")}</Title>
<Text size="xs" c="dimmed">
{t("exported", { date: exportDate })}
</Text>
</div>
<Badge variant="light" color="gray" ml="auto">
Homarr {analysis.metadata.homarrVersion}
</Badge>
</Group>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("entity")}</Table.Th>
<Table.Th ta="right">{t("count")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{PREVIEW_TABLE_KEYS.map((key) => (
<Table.Tr key={key}>
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
<Table.Td ta="right">
<Group gap={4} justify="flex-end">
{(analysis.counts[key] ?? 0) > 0 && (
<IconCheck size={14} color="var(--mantine-color-green-6)" />
)}
<Text size="sm" ff="monospace">{analysis.counts[key] ?? 0}</Text>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{analysis.boardNames.length > 0 && (
<Group gap="xs" wrap="wrap">
<Text size="sm" fw={500}>{t("boards")}:</Text>
{analysis.boardNames.map((name) => (
<Badge key={name} variant="light" size="sm">{name}</Badge>
))}
</Group>
)}
</Stack>
</Paper>
{hasPendingMigrations && (
<Paper p="md" radius="md" withBorder bd="1px solid var(--mantine-color-orange-8)">
<Group gap="sm">
<ThemeIcon variant="light" color="orange" size="lg" radius="xl">
<IconGitBranch size={18} />
</ThemeIcon>
<div>
<Text size="sm" fw={600}>
{pendingCount === 1
? t("migrationsApplied", { count: String(pendingCount) })
: t("migrationsAppliedPlural", { count: String(pendingCount) })}
</Text>
<Text size="xs" c="dimmed">
{pendingCount === 1
? t("migrationsOlderVersion", { count: String(pendingCount) })
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
</Text>
</div>
<Badge variant="light" color="orange" ml="auto">
{t("migrationsPending", { count: String(pendingCount) })}
</Badge>
</Group>
</Paper>
)}
{!hasPendingMigrations && (
<Paper p="md" radius="md" withBorder>
<Group gap="sm">
<ThemeIcon variant="light" color="green" size="lg" radius="xl">
<IconGitBranch size={18} />
</ThemeIcon>
<Text size="sm" fw={500} c="dimmed">
{t("migrationsUpToDate", { count: String(analysis.migrations.total) })}
</Text>
</Group>
</Paper>
)}
</Stack>
);
};
@@ -1,91 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react";
const CONFIRMATION_PHRASE = "I understand";
interface DangerousActionConfirmationProps {
title: string;
warningTitle: string;
warningBody: string;
typePrompt: string;
submitLabel: string;
submitIcon: ReactNode;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
disabled?: boolean;
color?: string;
}
export const DangerousActionConfirmation = ({
title,
warningTitle,
warningBody,
typePrompt,
submitLabel,
submitIcon,
cancelLabel,
onConfirm,
onCancel,
disabled,
color = "red",
}: DangerousActionConfirmationProps) => {
const [value, setValue] = useState("");
const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase();
return (
<Paper p="lg" radius="md" withBorder bd={`1px solid var(--mantine-color-${color}-9)`}>
<Stack gap="md">
<Group gap="sm">
<IconShieldExclamation size={24} color={`var(--mantine-color-${color}-6)`} />
<Text size="lg" fw={700} c={color}>
{title}
</Text>
</Group>
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
<Stack gap={4}>
<Text size="sm" fw={600}>{warningTitle}</Text>
<Text size="sm">{warningBody}</Text>
</Stack>
</Alert>
<Box>
<Text size="sm" fw={500} mb={4}>
{typePrompt}
</Text>
<TextInput
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder={CONFIRMATION_PHRASE}
disabled={disabled}
styles={{
input: {
fontFamily: "var(--mantine-font-family-monospace)",
borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined,
},
}}
/>
</Box>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
{cancelLabel}
</Button>
<Button
color={color}
leftSection={submitIcon}
onClick={onConfirm}
disabled={!isMatch || disabled}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Paper>
);
};
@@ -1,227 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
import type { RestoreStep } from "./types";
import { BackupPreviewPanel } from "./backup-preview-panel";
import { MigrationProgressPanel } from "./migration-progress-panel";
import { RestoreConfirmation } from "./restore-confirmation";
import { RestoreProgressPanel } from "./restore-progress-panel";
import { useBackupAnalysis } from "./use-backup-analysis";
interface DatabaseRestoreFlowProps {
variant?: "card" | "standalone";
onRestoreComplete?: () => void;
}
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
const t = useScopedI18n("management.page.tool.backup.restore");
const [file, setFile] = useState<FileWithPath | null>(null);
const [step, setStep] = useState<RestoreStep>("upload");
const [importError, setImportError] = useState<string | null>(null);
const apiDoneRef = useRef(false);
const animDoneRef = useRef(false);
const apiErrorRef = useRef<string | null>(null);
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
const handleFileDrop = useCallback(
(files: FileWithPath[]) => {
const droppedFile = files[0];
if (!droppedFile) return;
setFile(droppedFile);
setStep("preview");
setImportError(null);
void analyzeFile(droppedFile);
},
[analyzeFile],
);
const handleClear = useCallback(() => {
setFile(null);
setStep("upload");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
reset();
}, [reset]);
const tryFinalize = useCallback(() => {
if (!apiDoneRef.current) return;
if (apiErrorRef.current) {
setImportError(apiErrorRef.current);
setStep("error");
return;
}
if (!animDoneRef.current) return;
onRestoreComplete?.();
window.location.reload();
}, [onRestoreComplete]);
const handleConfirm = useCallback(async () => {
if (!file) return;
setStep("restoring");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
if (!response.ok) {
try {
const data = await response.json();
apiErrorRef.current = data.error ?? t("failed.title");
} catch {
apiErrorRef.current = `Server returned ${response.status}`;
}
}
} catch {
apiErrorRef.current = t("failed.title");
} finally {
apiDoneRef.current = true;
tryFinalize();
}
}, [file, t, tryFinalize]);
const handleAnimationComplete = useCallback(() => {
animDoneRef.current = true;
tryFinalize();
}, [tryFinalize]);
if (step === "upload") {
return (
<Dropzone
onDrop={handleFileDrop}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
multiple={false}
maxSize={1024 * 1024 * 256}
radius="md"
>
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{t("dropzone.title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{t("dropzone.description")}
</Text>
</div>
</Group>
</Dropzone>
);
}
if (step === "preview") {
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<IconFileZip size={20} />
<div>
<Text size="sm" fw={500}>{file?.name}</Text>
<Text size="xs" c="dimmed">{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}</Text>
</div>
</Group>
<Button variant="subtle" size="xs" onClick={handleClear}>
{t("changeFile")}
</Button>
</Group>
{loading && migrationProgress && (
<MigrationProgressPanel progress={migrationProgress} />
)}
{loading && !migrationProgress && (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
</Group>
)}
{analysisError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("analysisError")}>
{analysisError}
</Alert>
)}
{analysis && (
<>
<BackupPreviewPanel analysis={analysis} />
<Group justify="flex-end">
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => setStep("confirm")}
>
{t("continueToRestore")}
</Button>
</Group>
</>
)}
</Stack>
);
}
if (step === "confirm") {
return (
<Stack gap="md">
{analysis && <BackupPreviewPanel analysis={analysis} />}
<RestoreConfirmation
onConfirm={handleConfirm}
onCancel={() => setStep("preview")}
/>
</Stack>
);
}
if (step === "restoring") {
return (
<RestoreProgressPanel
active
migrations={analysis?.migrations.pending ?? []}
onComplete={handleAnimationComplete}
/>
);
}
if (step === "error") {
return (
<Stack gap="md">
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("failed.title")} radius="md">
<Text size="sm">{importError}</Text>
</Alert>
<Group>
<Button variant="subtle" onClick={handleClear}>
{t("tryAgain")}
</Button>
</Group>
</Stack>
);
}
return null;
};
@@ -1,3 +0,0 @@
export { DatabaseRestoreFlow } from "./database-restore-flow";
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
export type { BackupAnalysis, RestoreStep } from "./types";
@@ -1,60 +0,0 @@
"use client";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationProgress } from "./use-backup-analysis";
interface MigrationProgressPanelProps {
progress: MigrationProgress;
}
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.migration");
return (
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
<IconDatabase size={12} />
</ThemeIcon>
<Text size="sm" fw={600}>
{t("title", { current: String(progress.current), total: String(progress.total) })}
</Text>
</Group>
<Box pl={4}>
<Timeline active={progress.current - 1} bulletSize={20} lineWidth={2} color="blue">
{Array.from({ length: progress.total }, (_, i) => {
const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done");
const isActive = i === progress.current - 1 && progress.phase === "applying";
return (
<Timeline.Item
key={i}
bullet={
isDone ? (
<IconCheck size={12} />
) : isActive ? (
<Loader size={10} color="white" />
) : (
<IconLoader size={12} />
)
}
title={
<Text size="xs" ff="monospace" c={isDone ? "dimmed" : isActive ? "white" : "dimmed"}>
{i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })}
</Text>
}
/>
);
})}
</Timeline>
</Box>
</Stack>
</Paper>
);
};
@@ -1,32 +0,0 @@
"use client";
import { IconDatabaseImport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
interface RestoreConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
disabled?: boolean;
}
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
return (
<DangerousActionConfirmation
title={t("title")}
warningTitle={t("warningTitle")}
warningBody={t("warningBody")}
typePrompt={t("typePrompt", { phrase: "I understand" })}
submitLabel={t("submit")}
submitIcon={<IconDatabaseImport size={16} />}
cancelLabel={t("cancel")}
onConfirm={onConfirm}
onCancel={onCancel}
disabled={disabled}
/>
);
};
@@ -1,159 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import {
IconCheck,
IconDatabaseImport,
IconFileZip,
IconKey,
IconLoader,
IconRefresh,
IconTransform,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationFile } from "./types";
const BASE_PHASE_DURATIONS: Record<string, number> = {
extracting: 1200,
encrypting: 1500,
swapping: 800,
restarting: 10000,
};
const MIGRATION_STEP_DURATION = 100;
interface TimelineStep {
key: string;
label: string;
icon: Icon;
duration: number;
}
interface RestoreProgressPanelProps {
active: boolean;
migrations: MigrationFile[];
onComplete?: () => void;
}
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.progress");
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
const steps = useMemo((): TimelineStep[] => {
const result: TimelineStep[] = [
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
];
if (migrations.length > 0) {
for (const migration of migrations) {
result.push({
key: `migration-${migration.idx}`,
label: migration.tag,
icon: IconDatabaseImport,
duration: MIGRATION_STEP_DURATION,
});
}
} else {
result.push({
key: "migrating",
label: t("migrating"),
icon: IconDatabaseImport,
duration: 300,
});
}
result.push(
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! },
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! },
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! },
);
return result;
}, [migrations, t]);
useEffect(() => {
if (!active) return;
let cancelled = false;
const run = async () => {
for (let i = 0; i < steps.length; i++) {
if (cancelled) return;
setCurrentStepIdx(i);
await new Promise((r) => setTimeout(r, steps[i]!.duration));
}
if (!cancelled) {
onComplete?.();
}
};
void run();
return () => { cancelled = true; };
}, [active, onComplete, steps]);
if (!active && currentStepIdx === -1) return null;
return (
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
<Stack gap="md">
<Group gap="xs">
<Loader size="sm" color="blue" />
<Text size="sm" fw={600}>
{t("title")}
</Text>
</Group>
<Box pl={4}>
<Timeline
active={currentStepIdx}
bulletSize={24}
lineWidth={2}
color="blue"
>
{steps.map((step, i) => {
const isDone = i < currentStepIdx;
const isActive = i === currentStepIdx;
const isMigration = step.key.startsWith("migration-");
return (
<Timeline.Item
key={step.key}
bullet={
isDone ? (
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
<IconCheck size={14} />
</ThemeIcon>
) : isActive ? (
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
<Loader size={12} color="white" />
</ThemeIcon>
) : (
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
<IconLoader size={14} />
</ThemeIcon>
)
}
>
<Group gap="xs" mt={-4}>
<step.icon size={16} color={isDone ? "var(--mantine-color-green-6)" : isActive ? "var(--mantine-color-blue-5)" : "var(--mantine-color-dimmed)"} />
<Text
size={isMigration ? "xs" : "sm"}
fw={isActive ? 600 : 400}
ff={isMigration ? "monospace" : undefined}
c={isDone ? "green" : isActive ? "white" : "dimmed"}
>
{step.label}
</Text>
</Group>
</Timeline.Item>
);
})}
</Timeline>
</Box>
</Stack>
</Paper>
);
};
@@ -1,30 +0,0 @@
export interface BackupMetadata {
homarrVersion: string;
exportedAt: string;
dbDialect: string;
encryptionKey?: string;
}
export interface MigrationFile {
idx: number;
tag: string;
sql: string;
when: number;
}
export interface MigrationStatus {
applied: number;
pending: MigrationFile[];
total: number;
}
export interface BackupAnalysis {
metadata: BackupMetadata;
counts: Record<string, number>;
boardNames: string[];
migrations: MigrationStatus;
}
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
@@ -1,187 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const getAppliedMigrationCount = (db: any): number => {
try {
const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`);
return (result[0]?.values[0]?.[0] as number) ?? 0;
} catch {
return 0;
}
};
const fetchMigrations = async (): Promise<MigrationFile[]> => {
const res = await fetch("/api/backup/migrations");
if (!res.ok) throw new Error("Failed to fetch migration files from server");
const data = await res.json();
return data.migrations ?? [];
};
const countEntities = (db: any): Record<string, number> => {
const counts: Record<string, number> = {};
for (const key of PREVIEW_TABLE_KEYS) {
try {
const result = db.exec(`SELECT COUNT(*) FROM "${key}"`);
counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0;
} catch {
counts[key] = 0;
}
}
return counts;
};
const getBoardNames = (db: any): string[] => {
try {
const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"');
return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? [];
} catch {
return [];
}
};
export interface MigrationProgress {
current: number;
total: number;
tag: string;
phase: "pending" | "applying" | "done" | "error";
}
export const useBackupAnalysis = () => {
const t = useScopedI18n("management.page.tool.backup.restore");
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress | null>(null);
const dbRef = useRef<any>(null);
const analyzeFile = useCallback(async (file: File) => {
setLoading(true);
setError(null);
setAnalysis(null);
setMigrationProgress(null);
try {
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
import("jszip").then((m) => m.default),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import("sql.js").then((m: any) => m.default),
fetchMigrations(),
fetch("/api/backup/sql-wasm").then((r) => {
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
return r.arrayBuffer();
}),
]);
const arrayBuffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const metadataFile = zip.file("metadata.json");
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
const dbFile = zip.file("db.sqlite");
if (!dbFile) throw new Error(t("invalidMissingDb"));
const metadata = JSON.parse(await metadataFile.async("string"));
const dbBuffer = await dbFile.async("uint8array");
const SQL = await initSqlJs({ wasmBinary });
const db = new SQL.Database(dbBuffer);
dbRef.current = db;
try {
const appliedCount = getAppliedMigrationCount(db);
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
const migrations: MigrationStatus = {
applied: appliedCount,
pending: pendingMigrations,
total: allMigrations.length,
};
if (pendingMigrations.length > 0) {
for (let i = 0; i < pendingMigrations.length; i++) {
const migration = pendingMigrations[i]!;
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "applying",
});
await delay(100);
try {
const statements = migration.sql
.split("--> statement-breakpoint")
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements) {
db.run(stmt);
}
db.run(
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
[migration.tag, Date.now()],
);
} catch (migrationErr) {
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "error",
});
throw new Error(
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
);
}
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "done",
});
await delay(50);
}
}
const counts = countEntities(db);
const boardNames = getBoardNames(db);
setMigrationProgress(null);
setAnalysis({ metadata, counts, boardNames, migrations });
} finally {
db.close();
dbRef.current = null;
}
} catch (err) {
setError(err instanceof Error ? err.message : t("analyzeError"));
} finally {
setLoading(false);
}
}, [t]);
const reset = useCallback(() => {
if (dbRef.current) {
try { dbRef.current.close(); } catch { /* already closed */ }
dbRef.current = null;
}
setAnalysis(null);
setError(null);
setMigrationProgress(null);
}, []);
return { analysis, loading, error, migrationProgress, analyzeFile, reset };
};
@@ -0,0 +1,138 @@
"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>
);
};
@@ -0,0 +1,35 @@
"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>
);
};
-1
View File
@@ -2,4 +2,3 @@ declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";
declare module "sql.js";
+153
View File
@@ -0,0 +1,153 @@
# 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
+1 -1
View File
@@ -23,7 +23,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 256M;
client_max_body_size 32M;
}
}
}
+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.17.0",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"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.7",
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
"react-is": "^19.2.6",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
"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.16"
"vite@>=5.0.0 <=5.4.18": ">=8.0.15"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
+1
View File
@@ -22,6 +22,7 @@
},
"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,6 +28,7 @@ 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
+134 -19
View File
@@ -35,6 +35,12 @@ 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";
@@ -43,6 +49,9 @@ import {
boardChangeVisibilitySchema,
boardCreateSchema,
boardDuplicateSchema,
boardExportBundleSchema,
boardImportBundleSchema,
boardNameSchema,
boardRenameSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
@@ -54,6 +63,7 @@ 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";
@@ -1327,6 +1337,113 @@ 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",
});
}
}),
});
/**
@@ -1604,24 +1721,22 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
collapsed: collapseStates.at(0)?.collapsed ?? false,
}),
),
items: items
.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
)
.filter((item) => item !== null),
items: items.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
),
};
};
@@ -1637,7 +1752,7 @@ const parseItem = (item: unknown) => {
const result = outputItemSchema.safeParse(item);
if (!result.success) {
return null;
throw new Error(result.error.message);
}
return result.data;
};
+42
View File
@@ -0,0 +1,42 @@
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,7 +3,8 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
import { getServerSettingsAsync } from "@homarr/db/queries";
import {
dockerContainersRequestHandler,
getContainerLogsAsync,
@@ -108,6 +109,32 @@ 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,5 +1,8 @@
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,
@@ -7,6 +10,7 @@ import {
importInitialOldmarrInputSchema,
} from "@homarr/old-import/import";
import packageJson from "../../../../../package.json";
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
@@ -19,12 +23,7 @@ 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);
@@ -40,4 +39,28 @@ 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,6 +50,19 @@ 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[]) =>
({
@@ -73,6 +86,8 @@ const validInputs: {
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
getDiscoveredPreview: undefined,
syncDiscovery: undefined,
logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 },
};
+36 -14
View File
@@ -52,23 +52,45 @@ export const userRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
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 existingUser = await ctx.db.query.users.findFirst({
where: eq(users.email, input.email),
});
await ctx.db.insert(groupPermissions).values({
groupId,
permission: "admin",
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(groupMembers).values({
groupId,
userId,
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)),
});
if (!existingMembership) {
await ctx.db.insert(groupMembers).values({
groupId,
userId,
});
}
await nextOnboardingStepAsync(ctx.db, undefined);
}),
register: publicProcedure
+9
View File
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,40 @@
{
"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:"
}
}
@@ -0,0 +1,178 @@
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");
});
});
@@ -0,0 +1,237 @@
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");
});
});
@@ -0,0 +1,118 @@
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;
};
@@ -0,0 +1,59 @@
/**
* 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;
@@ -0,0 +1,215 @@
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`,
};
};
@@ -0,0 +1,23 @@
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";
@@ -0,0 +1,35 @@
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>>>;
@@ -0,0 +1,42 @@
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,
};
};
@@ -0,0 +1,168 @@
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,
};
};
@@ -0,0 +1,10 @@
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`;
};
@@ -0,0 +1,15 @@
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);
@@ -0,0 +1,76 @@
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,
};
};
@@ -0,0 +1,5 @@
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";
@@ -0,0 +1,111 @@
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" });
});
});
@@ -0,0 +1,111 @@
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, "") };
};
@@ -0,0 +1,346 @@
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(),
};
};
@@ -0,0 +1,21 @@
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 };
@@ -0,0 +1,38 @@
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([]);
});
});
@@ -0,0 +1,506 @@
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,
};
};
@@ -0,0 +1,4 @@
export { parseBundleJson } from "./parse-bundle";
export { prepareBoardImport, type PreparedBundleImport } from "./prepare-board";
export { importBoardBundleAsync, type BundleImportReport } from "./insert";
export { doAppsMatch } from "./match-apps";
@@ -0,0 +1,79 @@
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);
},
});
};
@@ -0,0 +1,39 @@
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,
};
};
@@ -0,0 +1,14 @@
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
);
};
@@ -0,0 +1,7 @@
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);
};
@@ -0,0 +1,175 @@
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,
};
};
@@ -0,0 +1,203 @@
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,
};
};
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,199 @@
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
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+1
View File
@@ -26,6 +26,7 @@
"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",
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,10 @@
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],
});
+2 -1
View File
@@ -1,12 +1,13 @@
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, resetPassword, fixUsernames, recreateAdmin];
const commands = [usersRoot, integrationsRoot, boardsRoot, resetPassword, fixUsernames, recreateAdmin];
void run(commands, {
name: "homarr-cli",
+1
View File
@@ -30,6 +30,7 @@
"@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",
@@ -0,0 +1,17 @@
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,6 +281,13 @@
"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
}
]
}
}
@@ -0,0 +1,17 @@
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");
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more