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.
This commit is contained in:
Thomas Camlong committed 2026-06-04 10:56:42 +02:00
1 parent 63d8fa8c26
commit fb8721bd4e
100 files changed
+13394 -56

No files matched your search

@@ -0,0 +1,160 @@
"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 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>
{(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>
)}
{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,48 @@
"use client";
import { List, Text } from "@mantine/core";
import type { ConfigEntityCounts } 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);
type ConfigSummaryListProps = {
counts: ConfigEntityCounts;
t: (key: string, values?: Record<string, unknown>) => string;
};
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
const entries = countEntries(counts);
if (entries.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("management.page.importExport.summary.empty")}
</Text>
);
}
return (
<List size="sm" spacing={4}>
{entries.map((key) => (
<List.Item key={key}>
{t(countLabelKeys[key], { count: counts[key] })}
</List.Item>
))}
</List>
);
};