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
This commit is contained in:
Thomas Camlong committed 2026-06-06 16:22:59 +02:00
1 parent 1f7b7f0516
commit f6a598b774
48 files changed
+1869 -1435

No files matched your search

@@ -1,68 +0,0 @@
"use client";
import { Group, rem, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconJson, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
interface ConfigImportDropZoneProps {
loading: boolean;
updateFile: (file: FileWithPath) => void;
}
export const ConfigImportDropZone = ({ loading, updateFile }: ConfigImportDropZoneProps) => {
const tDropzone = useScopedI18n("init.step.import.configDropzone");
return (
<Dropzone
onDrop={(files) => {
const firstFile = files[0];
if (!firstFile) return;
updateFile(firstFile);
}}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.json]}
loading={loading}
multiple={false}
maxSize={1024 * 1024 * 64}
onReject={(rejections) => {
console.error(
"Rejected files",
rejections.map(
(rejection) =>
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
),
);
}}
>
<Group justify="center" gap="xl" mih={220} 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>
<IconJson
style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }}
stroke={1.5}
/>
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{tDropzone("title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{tDropzone("description")}
</Text>
</div>
</Group>
</Dropzone>
);
};
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Card, Stack } from "@mantine/core";
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";
@@ -11,13 +12,14 @@ import { useI18n } from "@homarr/translation/client";
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
import { ConfigImportDropZone } from "./config-import-dropzone";
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,
@@ -32,6 +34,12 @@ export const InitConfigImport = () => {
},
});
useEffect(() => {
if (hasOpenedRef.current) return;
hasOpenedRef.current = true;
setTimeout(() => inputRef.current?.click(), 100);
}, []);
useEffect(() => {
if (!fileContent) {
resetPreview();
@@ -43,9 +51,7 @@ export const InitConfigImport = () => {
const importAllowed = preview?.compatibility.status === "compatible";
const handleImport = async () => {
if (!file || !importAllowed) {
return;
}
if (!file || !importAllowed) return;
await mutateAsync(
{ content: fileContent },
@@ -60,39 +66,51 @@ export const InitConfigImport = () => {
);
};
if (!file) {
return (
<Card w={64 * 12 + 8} maw="90vw">
<ConfigImportDropZone
loading={previewLoading}
updateFile={(selectedFile) => {
void selectedFile.text().then((content) => {
setFileContent(content);
setFile(selectedFile);
});
}}
/>
</Card>
);
}
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 mb="sm" w={64 * 12 + 8} maw="90vw">
<FileInfoCard
file={file}
onRemove={() => {
setFile(null);
setFileContent("");
}}
<Stack w="100%" mb="sm">
<input
ref={inputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
style={{ display: "none" }}
/>
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
<Button
loading={isPending}
disabled={!importAllowed || previewLoading}
onClick={() => void handleImport()}
>
{t("init.step.import.configImport.action")}
</Button>
{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>
);
};
@@ -21,6 +21,10 @@ export const InitImport = () => {
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>(
@@ -18,6 +18,7 @@ export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSet
<>
<TextInput
{...form.getInputProps("targetBoardName")}
value={form.getInputProps("targetBoardName").value ?? ""}
label={tDocker("targetBoardName.label")}
description={tDocker("targetBoardName.description")}
/>
@@ -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]);
@@ -107,10 +107,10 @@ export const ImportConfigCard = () => {
{importResult && (
<Alert color="green" icon={<IconCheck />}>
<List size="sm">
<List.Item>{importResult.boards} boards imported</List.Item>
<List.Item>{importResult.apps} apps created</List.Item>
<List.Item>{importResult.integrations} integrations created</List.Item>
<List.Item>{importResult.groups} groups created</List.Item>
<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>
)}
@@ -13,6 +13,31 @@ const compatibilityAlertColor: Record<ConfigImportPreview["compatibility"]["stat
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;
@@ -78,69 +103,22 @@ export const ConfigImportPreviewPanel = ({ preview, previewLoading, t }: ConfigI
<ConfigSummaryList counts={preview.toCreate} t={t} />
</Stack>
{(preview.toReuse.apps > 0 || preview.toReuse.integrations > 0) && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillReuse")}
</Text>
<List size="sm">
{preview.toReuse.apps > 0 && (
<List.Item>
{t("management.page.importExport.preview.reuseApps", { count: preview.toReuse.apps })}
</List.Item>
)}
{preview.toReuse.integrations > 0 && (
<List.Item>
{t("management.page.importExport.preview.reuseIntegrations", {
count: preview.toReuse.integrations,
})}
</List.Item>
)}
</List>
</Stack>
)}
{(preview.toSkip.boards > 0 || preview.toSkip.groups > 0 || preview.toSkip.searchEngines > 0) && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillSkip")}
</Text>
<List size="sm">
{preview.toSkip.boards > 0 && (
<List.Item>
{t("management.page.importExport.preview.skipBoards", { count: preview.toSkip.boards })}
</List.Item>
)}
{preview.toSkip.groups > 0 && (
<List.Item>
{t("management.page.importExport.preview.skipGroups", { count: preview.toSkip.groups })}
</List.Item>
)}
{preview.toSkip.searchEngines > 0 && (
<List.Item>
{t("management.page.importExport.preview.skipSearchEngines", {
count: preview.toSkip.searchEngines,
})}
</List.Item>
)}
</List>
</Stack>
)}
{preview.toUpdate.serverSettings > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillUpdate")}
</Text>
<List size="sm">
<List.Item>
{t("management.page.importExport.preview.updateServerSettings", {
count: preview.toUpdate.serverSettings,
})}
</List.Item>
</List>
</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" />}>
@@ -2,23 +2,10 @@
import { List, Text } from "@mantine/core";
import type { ConfigEntityCounts } from "@homarr/board-portability";
import type { ConfigEntityCounts, ConfigEntityKey } from "@homarr/board-portability";
import { CONFIG_ENTITY_KEYS } from "@homarr/board-portability";
const countLabelKeys: Record<keyof ConfigEntityCounts, string> = {
boards: "management.page.importExport.summary.boards",
apps: "management.page.importExport.summary.apps",
integrations: "management.page.importExport.summary.integrations",
secrets: "management.page.importExport.summary.secrets",
widgets: "management.page.importExport.summary.widgets",
sections: "management.page.importExport.summary.sections",
layouts: "management.page.importExport.summary.layouts",
searchEngines: "management.page.importExport.summary.searchEngines",
groups: "management.page.importExport.summary.groups",
serverSettings: "management.page.importExport.summary.serverSettings",
};
const countEntries = (counts: ConfigEntityCounts) =>
(Object.keys(countLabelKeys) as (keyof ConfigEntityCounts)[]).filter((key) => counts[key] > 0);
const TRANSLATION_PREFIX = "management.page.importExport.summary";
type ConfigSummaryListProps = {
counts: ConfigEntityCounts;
@@ -26,21 +13,21 @@ type ConfigSummaryListProps = {
};
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
const entries = countEntries(counts);
const entries = CONFIG_ENTITY_KEYS.filter((key) => counts[key] > 0);
if (entries.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("management.page.importExport.summary.empty")}
{t(`${TRANSLATION_PREFIX}.empty`)}
</Text>
);
}
return (
<List size="sm" spacing={4}>
{entries.map((key) => (
{entries.map((key: ConfigEntityKey) => (
<List.Item key={key}>
{t(countLabelKeys[key], { count: counts[key] })}
{t(`${TRANSLATION_PREFIX}.${key}`, { count: counts[key] })}
</List.Item>
))}
</List>