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