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:
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>
|
||||
|
||||
Reference in new issue
Block a user