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
@@ -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