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,4 +1,5 @@
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
.dockerignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
@@ -14,3 +15,10 @@ apps/nextjs/.next
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.docker-data
|
||||
.docker-cache
|
||||
coverage
|
||||
.cursor
|
||||
agent-transcripts
|
||||
**/*.spec.ts
|
||||
**/*.test.ts
|
||||
@@ -72,3 +72,7 @@ en.d.json.ts
|
||||
# personal agent config
|
||||
AGENTS.md
|
||||
.opencode
|
||||
|
||||
# local docker dev
|
||||
.docker-data/
|
||||
.docker-cache/
|
||||
@@ -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>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Config Portability & Import/Export
|
||||
|
||||
## New features
|
||||
|
||||
### Full Configuration Export & Import (`/manage/tools/import-export`)
|
||||
|
||||
New dedicated tool page under **Manage → Tools → Import / Export** with:
|
||||
|
||||
- **Export Full Configuration** — downloads a JSON bundle containing all boards, apps, integrations (with encrypted secrets), server settings, search engines, and groups. The encryption key is embedded so secrets can be restored on another instance. A popover with warnings and a preview summary is shown before download.
|
||||
- **Import Homarr Configuration** — upload a previously exported JSON file. Shows a compatibility check, a preview of what will be created / reused / skipped / updated, then imports everything transactionally. Server settings that haven't changed are no longer counted as "Will update".
|
||||
- **Import Homepage services.yaml** — parse a Homepage dashboard `services.yaml`, resolve `{{HOMEPAGE_VAR_*}}` template variables with a form, and create a board with apps and integrations.
|
||||
- **Docker Label Discovery** — scan running Docker containers for `homarr.*` or `homepage.*` labels and sync discovered services into boards. Moved here from the Docker page. Discovery is now triggered manually via the **Scan** button (the background cron job was removed).
|
||||
|
||||
### Onboarding: Import Homarr JSON
|
||||
|
||||
The onboarding flow (`/init`) now offers a third option:
|
||||
|
||||
- **Import Homarr configuration (.json)** — alongside "Start from scratch" and "Import from Homarr before 1.0"
|
||||
- Uses the same preview panel (compatibility, create/reuse/skip/update counts) as the admin import tool
|
||||
- Boards are created with `creatorId: null` (no admin user exists yet at this step)
|
||||
|
||||
### Board Portability package (`@homarr/board-portability`)
|
||||
|
||||
New package with:
|
||||
|
||||
- **Schema** (`homarrConfigBundleSchema`) — v2.0 bundle format with boards, apps, integrations (encrypted secrets), server settings, search engines, groups
|
||||
- **Export** — `exportFullConfigAsync`, `previewExportFullConfigAsync`, `serializeBoard`, `toJson`
|
||||
- **Import** — `importFullConfigAsync`, `previewImportFullConfigAsync`, `planFullConfigImport`, `parseBundle`, `matchApps`
|
||||
- **Homepage** — `parseServicesYaml`, `prepareHomepageImport`, `extractHomepageEnvVariables`, `replaceHomepageEnvVariables`, widget type mapping
|
||||
- **Compat** — `assessBundleCompatibility`, `parseConfigBundleJson`, format version validation
|
||||
|
||||
### Docker Label Discovery (`@homarr/docker/discovery`)
|
||||
|
||||
- `listDiscoveredContainersAsync` — scans Docker hosts for labeled containers
|
||||
- `parseContainerLabels` — parses `homarr.*` and `homepage.*` label sets
|
||||
- `syncDiscoveredServicesAsync` — creates/updates apps and board items from discovered services
|
||||
- `docker_app_source` table + migrations (SQLite/MySQL/PostgreSQL) — tracks which apps were created by discovery
|
||||
|
||||
### CLI commands (`@homarr/cli`)
|
||||
|
||||
- `boards list` — list all boards
|
||||
- `boards export` — export a board bundle by name
|
||||
|
||||
### Export/Import modals (`@homarr/modals-collection`)
|
||||
|
||||
- `ExportBoardModal` — single-board export
|
||||
- `ImportBundleModal` — single-board bundle import
|
||||
- `ImportHomepageModal` — Homepage services.yaml import (also used from board card menu)
|
||||
|
||||
### Example docker-compose (`examples/docker-compose.yml`)
|
||||
|
||||
Media stack (Sonarr, Radarr, Prowlarr, qBittorrent, Jellyfin, Overseerr) with `homarr.*` labels for discovery.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- **xterm.js crash** — removed `@xterm/addon-canvas` which is incompatible with `@xterm/xterm` v6 (canvas renderer was removed upstream). The logs terminal now uses the built-in DOM renderer. This fixed `TypeError: can't access property "onShowLinkUnderline", this._linkifier2 is undefined` that appeared when navigating between pages.
|
||||
- **Homepage env variable editing** — fixed `MISSING_MESSAGE: field.envVariables.label` (wrong i18n namespace in import-homepage-card) and `TypeError: can't access property "value", t.currentTarget is null` (React 19 null `currentTarget` in onChange handlers).
|
||||
|
||||
## Changes
|
||||
|
||||
- **Docker Label Discovery** is no longer a background cron job. The `discoveryEnabled` server setting was removed. Discovery is now triggered manually from the Import/Export page.
|
||||
- **Docker settings form** no longer shows the "Enable Docker label discovery" toggle.
|
||||
- **Import/Export sidebar entry** added under Manage → Tools.
|
||||
- **Board card menu** — removed bundle/homepage import options from the boards create menu (they live in Import/Export now).
|
||||
- **Server settings preview** compares serialized values and only counts settings that actually differ (re-importing the same export shows 0 updates).
|
||||
- **Shared UI components** — `ConfigImportPreviewPanel` and `ConfigSummaryList` extracted to `~/components/config-import/` for reuse between the admin tool and onboarding.
|
||||
+101
-48
@@ -1,50 +1,31 @@
|
||||
# Homarr Docker label discovery example.
|
||||
# Prerequisites: Homarr with ENABLE_DOCKER, Docker socket mounted, and in Manage → Settings → Docker
|
||||
# set a target board name (or label services with homarr.board). Restart Homarr after upgrade so
|
||||
# migration 0037_add_docker_app_source creates the docker_app_source table.
|
||||
# Optional: attach this stack to Homarr's network so href/ping hostnames resolve inside Docker.
|
||||
# This compose file demonstrates all supported label combinations for testing.
|
||||
# Prerequisites: Homarr with Docker socket mounted.
|
||||
# In Manage → Tools → Import/Export, use the Docker Discovery panel to scan.
|
||||
|
||||
services:
|
||||
# ─── Full Homarr labels (all fields) ─────────────────────────────────────────
|
||||
sonarr:
|
||||
image: lscr.io/linuxserver/sonarr:latest
|
||||
container_name: homarr-sonarr
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Etc/UTC
|
||||
volumes:
|
||||
- sonarr-config:/config
|
||||
- ./media:/media
|
||||
ports:
|
||||
- "8989:8989"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
# Homarr labels (native)
|
||||
homarr.name: "Sonarr"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://sonarr:8989"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
|
||||
homarr.description: "TV series management"
|
||||
homarr.ping: "http://sonarr:8989/ping"
|
||||
homarr.id: "sonarr-main"
|
||||
homarr.board: "homelab"
|
||||
homarr.integration: "sonarr"
|
||||
homarr.widget: "downloads"
|
||||
# Homepage labels (fallback — used if homarr.* are absent)
|
||||
homepage.group: "Media"
|
||||
homepage.name: "Sonarr"
|
||||
homepage.href: "http://sonarr:8989"
|
||||
homepage.icon: "sonarr"
|
||||
homepage.description: "TV series management"
|
||||
|
||||
radarr:
|
||||
image: lscr.io/linuxserver/radarr:latest
|
||||
container_name: homarr-radarr
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Etc/UTC
|
||||
volumes:
|
||||
- radarr-config:/config
|
||||
- ./media:/media
|
||||
ports:
|
||||
- "7878:7878"
|
||||
restart: unless-stopped
|
||||
@@ -57,27 +38,51 @@ services:
|
||||
homarr.ping: "http://radarr:7878/ping"
|
||||
homarr.integration: "radarr"
|
||||
homarr.widget: "downloads"
|
||||
homepage.group: "Media"
|
||||
homepage.name: "Radarr"
|
||||
homepage.href: "http://radarr:7878"
|
||||
homepage.icon: "radarr"
|
||||
homepage.description: "Movie management"
|
||||
|
||||
# ─── Minimal Homarr labels (required fields only) ────────────────────────────
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
container_name: homarr-portainer
|
||||
ports:
|
||||
- "9000:9000"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Portainer"
|
||||
homarr.group: "Infrastructure"
|
||||
homarr.href: "http://portainer:9000"
|
||||
|
||||
# ─── Homepage labels only (migration scenario) ───────────────────────────────
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: homarr-grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homepage.name: "Grafana"
|
||||
homepage.group: "Monitoring"
|
||||
homepage.href: "http://grafana:3000"
|
||||
homepage.icon: "grafana"
|
||||
homepage.description: "Metrics and dashboards"
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: homarr-prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homepage.name: "Prometheus"
|
||||
homepage.group: "Monitoring"
|
||||
homepage.href: "http://prometheus:9090"
|
||||
homepage.icon: "prometheus"
|
||||
|
||||
# ─── Both label sets (homarr takes priority) ─────────────────────────────────
|
||||
qbittorrent:
|
||||
image: lscr.io/linuxserver/qbittorrent:latest
|
||||
container_name: homarr-qbittorrent
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Etc/UTC
|
||||
- WEBUI_PORT=8080
|
||||
volumes:
|
||||
- qbittorrent-config:/config
|
||||
- ./downloads:/downloads
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "6881:6881"
|
||||
- "6881:6881/udp"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "qBittorrent"
|
||||
@@ -88,13 +93,61 @@ services:
|
||||
homarr.ping: "http://qbittorrent:8080"
|
||||
homarr.integration: "qBittorrent"
|
||||
homarr.widget: "downloads"
|
||||
homepage.group: "Downloads"
|
||||
homepage.name: "qBittorrent"
|
||||
homepage.href: "http://qbittorrent:8080"
|
||||
homepage.name: "qBittorrent (homepage)"
|
||||
homepage.group: "Downloads (homepage)"
|
||||
homepage.href: "http://qbittorrent:8080/homepage"
|
||||
homepage.icon: "qbittorrent"
|
||||
homepage.description: "Torrent client"
|
||||
homepage.description: "Should be ignored"
|
||||
|
||||
volumes:
|
||||
sonarr-config:
|
||||
radarr-config:
|
||||
qbittorrent-config:
|
||||
# ─── Hidden container (excluded from discovery) ──────────────────────────────
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
container_name: homarr-watchtower
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.hide: "true"
|
||||
homarr.name: "Watchtower"
|
||||
homarr.group: "Infrastructure"
|
||||
homarr.href: "http://watchtower:8080"
|
||||
|
||||
# ─── Targeting a specific board ──────────────────────────────────────────────
|
||||
traefik:
|
||||
image: traefik:latest
|
||||
container_name: homarr-traefik
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8081:8080"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Traefik"
|
||||
homarr.group: "Networking"
|
||||
homarr.href: "http://traefik:8080"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg"
|
||||
homarr.board: "networking"
|
||||
homarr.ping: "http://traefik:8080/ping"
|
||||
|
||||
# ─── Custom external ID ──────────────────────────────────────────────────────
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
container_name: homarr-jellyfin
|
||||
ports:
|
||||
- "8096:8096"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Jellyfin"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://jellyfin:8096"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg"
|
||||
homarr.description: "Media streaming"
|
||||
homarr.ping: "http://jellyfin:8096/health"
|
||||
homarr.id: "jellyfin-production"
|
||||
homarr.integration: "jellyfin"
|
||||
homarr.widget: "mediaServer"
|
||||
|
||||
# ─── No discovery labels (should be completely ignored) ──────────────────────
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: homarr-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
@@ -2,9 +2,9 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
assessBundleCompatibility,
|
||||
exportFullConfigAsync,
|
||||
importFullConfigAsync,
|
||||
parseAndValidateBundle,
|
||||
previewExportFullConfigAsync,
|
||||
previewImportFullConfigAsync,
|
||||
} from "@homarr/board-portability";
|
||||
@@ -19,11 +19,7 @@ export const configRouter = createTRPCRouter({
|
||||
|
||||
previewImport: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
|
||||
}),
|
||||
@@ -34,27 +30,13 @@ export const configRouter = createTRPCRouter({
|
||||
|
||||
importFull: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input.content);
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid JSON" });
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, packageJson.version);
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: compatibility.issues.join(" "),
|
||||
});
|
||||
}
|
||||
|
||||
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
|
||||
}),
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
assessBundleCompatibility,
|
||||
importFullConfigAsync,
|
||||
previewImportFullConfigAsync,
|
||||
} from "@homarr/board-portability";
|
||||
import { importFullConfigAsync, parseAndValidateBundle, previewImportFullConfigAsync } from "@homarr/board-portability";
|
||||
import { onboarding } from "@homarr/db/schema";
|
||||
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
|
||||
import {
|
||||
ensureValidTokenOrThrow,
|
||||
@@ -26,12 +23,7 @@ export const importRouter = createTRPCRouter({
|
||||
}),
|
||||
validateToken: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
checksum: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ checksum: z.string(), token: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
ensureValidTokenOrThrow(input.checksum, input.token);
|
||||
@@ -49,38 +41,26 @@ export const importRouter = createTRPCRouter({
|
||||
}),
|
||||
previewInitialConfigImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
|
||||
}),
|
||||
importInitialConfigImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input.content);
|
||||
} catch {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid JSON" });
|
||||
}
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
await importFullConfigAsync(ctx.db, bundle, null);
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, packageJson.version);
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: compatibility.issues.join(" "),
|
||||
});
|
||||
const hasUsers = (bundle.users ?? []).length > 0;
|
||||
if (hasUsers) {
|
||||
await ctx.db.update(onboarding).set({ previousStep: "import", step: "finish" });
|
||||
} else {
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
|
||||
await importFullConfigAsync(ctx.db, bundle, null);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
});
|
||||
@@ -50,14 +50,13 @@ vi.mock("@homarr/docker/env", () => ({
|
||||
ENABLE_DOCKER: true,
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/docker", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@homarr/docker")>();
|
||||
return {
|
||||
...original,
|
||||
listDiscoveredContainersAsync: async () => [],
|
||||
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
|
||||
};
|
||||
});
|
||||
vi.mock("@homarr/docker", () => ({
|
||||
DockerSingleton: {
|
||||
getInstances: () => [],
|
||||
},
|
||||
listDiscoveredContainersAsync: async () => [],
|
||||
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
|
||||
}));
|
||||
vi.mock("@homarr/db/queries", () => ({
|
||||
getServerSettingsAsync: async () => ({
|
||||
docker: { targetBoardName: null, readHomepageLabels: true, pruneRemoved: false },
|
||||
|
||||
@@ -52,23 +52,45 @@ export const userRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.email, input.email),
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
|
||||
const userId = existingUser ? existingUser.id : await createUserAsync(ctx.db, input);
|
||||
|
||||
const existingGroup = await ctx.db.query.groups.findFirst({
|
||||
where: eq(groups.name, credentialsAdminGroup),
|
||||
});
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
|
||||
let groupId: string;
|
||||
if (existingGroup) {
|
||||
groupId = existingGroup.id;
|
||||
} else {
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
}
|
||||
|
||||
const existingMembership = await ctx.db.query.groupMembers.findFirst({
|
||||
where: and(eq(groupMembers.groupId, groupId), eq(groupMembers.userId, userId)),
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
register: publicProcedure
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export {
|
||||
CONFIG_BUNDLE_FORMAT_VERSION,
|
||||
assessBundleCompatibility,
|
||||
parseAndValidateBundle,
|
||||
parseConfigBundleJson,
|
||||
} from "./src/config-bundle-compat";
|
||||
export { homarrBundleSchema, homarrConfigBundleSchema, type HomarrBundle, type HomarrConfigBundle } from "./src/schema";
|
||||
export type { ConfigEntityCounts, ConfigExportPreview, ConfigImportPreview } from "./src/types";
|
||||
export { USER_DIRECT_FIELDS, USER_REF_FIELDS, GROUP_REF_FIELDS } from "./src/entity-fields";
|
||||
export { resolveEntities, groupByKey, type EntityResolverDef, type ResolveResult } from "./src/resolve-entities";
|
||||
export { homarrBundleSchema, homarrConfigBundleSchema, type HomarrBundle, type HomarrConfigBundle, type ConfigBundleUser } from "./src/schema";
|
||||
export type { ConfigEntityCounts, ConfigEntityKey, ConfigExportPreview, ConfigImportPreview } from "./src/types";
|
||||
export { CONFIG_ENTITY_KEYS, emptyCounts } from "./src/types";
|
||||
export { exportBoardBundleAsync, bundleFilenameForBoard } from "./src/export";
|
||||
export { exportFullConfigAsync } from "./src/export/export-full-config";
|
||||
export { previewExportFullConfigAsync } from "./src/export/preview-export-full-config";
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { HomarrConfigBundle } from "../schema";
|
||||
import { homarrConfigBundleSchema } from "../schema";
|
||||
import { assessBundleCompatibility, parseConfigBundleJson } from "../config-bundle-compat";
|
||||
|
||||
const createMinimalConfigBundle = (overrides: Partial<HomarrConfigBundle> = {}): HomarrConfigBundle => ({
|
||||
version: "2.0",
|
||||
type: "full-config",
|
||||
exportedAt: new Date().toISOString(),
|
||||
homarrVersion: "1.5.0",
|
||||
encryptionKey: "a".repeat(64),
|
||||
boards: [
|
||||
{
|
||||
ref: "board-uuid-1",
|
||||
name: "Main Dashboard",
|
||||
settings: {
|
||||
primaryColor: "#FA5252",
|
||||
secondaryColor: "#868E96",
|
||||
opacity: 100,
|
||||
itemRadius: "md",
|
||||
},
|
||||
layouts: [
|
||||
{ ref: "layout-1", name: "Default", columnCount: 10, breakpoint: 0 },
|
||||
],
|
||||
sections: [
|
||||
{ ref: "section-1", kind: "category", name: "Media", yOffset: 0, xOffset: 0 },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
ref: "item-1",
|
||||
kind: "app",
|
||||
options: { appId: "app-ref-1", openInNewTab: true },
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
integrationRefs: ["int-ref-1"],
|
||||
appRef: "app-ref-1",
|
||||
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 0, y: 0, w: 2, h: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
ref: "app-ref-1",
|
||||
name: "Jellyfin",
|
||||
href: "https://jellyfin.local",
|
||||
iconUrl: "https://cdn.example/jellyfin.png",
|
||||
description: "Media server",
|
||||
pingUrl: null,
|
||||
},
|
||||
],
|
||||
integrations: [
|
||||
{
|
||||
ref: "int-ref-1",
|
||||
kind: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
url: "https://jellyfin.local",
|
||||
secrets: [{ kind: "apiKey", value: "encrypted.value" }],
|
||||
},
|
||||
],
|
||||
serverSettings: {
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
docker: { targetBoardName: null, readHomepageLabels: true },
|
||||
},
|
||||
searchEngines: [
|
||||
{
|
||||
ref: "se-1",
|
||||
iconUrl: "https://google.com/favicon.ico",
|
||||
name: "Google",
|
||||
short: "g",
|
||||
description: "Google search",
|
||||
urlTemplate: "https://google.com/search?q={query}",
|
||||
type: "generic",
|
||||
integrationRef: null,
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
ref: "group-1",
|
||||
name: "Admins",
|
||||
position: 0,
|
||||
ownerRef: "user-1",
|
||||
homeBoardRef: null,
|
||||
mobileHomeBoardRef: null,
|
||||
permissions: ["admin"],
|
||||
boardPermissions: [{ boardRef: "board-uuid-1", permission: "board-full-access" }],
|
||||
integrationPermissions: [{ integrationRef: "int-ref-1", permission: "integration-use-credentials" }],
|
||||
memberRefs: ["user-1"],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
ref: "user-1",
|
||||
name: "admin",
|
||||
email: "admin@example.com",
|
||||
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
|
||||
provider: "credentials",
|
||||
homeBoardRef: "board-uuid-1",
|
||||
mobileHomeBoardRef: null,
|
||||
defaultSearchEngineRef: "se-1",
|
||||
colorScheme: "dark",
|
||||
firstDayOfWeek: 1,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
pingIconsEnabled: false,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("v2.0 config bundle schema", () => {
|
||||
test("schema validates a valid config bundle", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("schema rejects v1.0 version for config bundle", () => {
|
||||
const invalid = { ...createMinimalConfigBundle(), version: "1.0" };
|
||||
const result = homarrConfigBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("schema rejects missing type field", () => {
|
||||
const { type: _type, ...invalid } = createMinimalConfigBundle();
|
||||
const result = homarrConfigBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("schema accepts bundle with no boards", () => {
|
||||
const bundle = createMinimalConfigBundle({ boards: [] });
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("schema accepts board with optional ref field", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
delete (bundle.boards[0] as Record<string, unknown>).ref;
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2.0 config bundle round-trip", () => {
|
||||
test("serialized config bundle parses back correctly", () => {
|
||||
const original = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(original);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
|
||||
expect(parsed.version).toBe("2.0");
|
||||
expect(parsed.type).toBe("full-config");
|
||||
expect(parsed.homarrVersion).toBe("1.5.0");
|
||||
expect(parsed.boards).toHaveLength(1);
|
||||
expect(parsed.boards[0]?.ref).toBe("board-uuid-1");
|
||||
expect(parsed.boards[0]?.name).toBe("Main Dashboard");
|
||||
expect(parsed.apps).toHaveLength(1);
|
||||
expect(parsed.integrations).toHaveLength(1);
|
||||
expect(parsed.integrations[0]?.secrets).toHaveLength(1);
|
||||
expect(parsed.searchEngines).toHaveLength(1);
|
||||
expect(parsed.groups).toHaveLength(1);
|
||||
expect(parsed.groups[0]?.boardPermissions).toHaveLength(1);
|
||||
expect(parsed.groups[0]?.boardPermissions[0]?.boardRef).toBe("board-uuid-1");
|
||||
});
|
||||
|
||||
test("preserves encryption key", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.encryptionKey).toBe("a".repeat(64));
|
||||
});
|
||||
|
||||
test("preserves server settings", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.serverSettings).toEqual({
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
docker: { targetBoardName: null, readHomepageLabels: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves users and group memberships", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
|
||||
expect(parsed.users).toHaveLength(1);
|
||||
expect(parsed.users![0]?.email).toBe("admin@example.com");
|
||||
expect(parsed.users![0]?.provider).toBe("credentials");
|
||||
expect(parsed.users![0]?.homeBoardRef).toBe("board-uuid-1");
|
||||
expect(parsed.groups[0]?.memberRefs).toEqual(["user-1"]);
|
||||
expect(parsed.groups[0]?.ownerRef).toBe("user-1");
|
||||
});
|
||||
|
||||
test("accepts bundle without users (backward compat)", () => {
|
||||
const { users: _users, ...bundleWithoutUsers } = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundleWithoutUsers);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.users).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assessBundleCompatibility", () => {
|
||||
test("marks valid bundle as compatible", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("compatible");
|
||||
expect(result.bundle).not.toBeNull();
|
||||
expect(result.compatibility.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("marks compatible even with version mismatch (different Homarr version)", () => {
|
||||
const bundle = createMinimalConfigBundle({ homarrVersion: "1.4.0" });
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("compatible");
|
||||
expect(result.compatibility.issues).toHaveLength(1);
|
||||
expect(result.compatibility.issues[0]).toContain("1.4.0");
|
||||
});
|
||||
|
||||
test("rejects unsupported format version", () => {
|
||||
const bundle = { ...createMinimalConfigBundle(), version: "3.0" };
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("unsupportedVersion");
|
||||
expect(result.bundle).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects non-object input", () => {
|
||||
const result = assessBundleCompatibility(null, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("invalidStructure");
|
||||
expect(result.bundle).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects malformed structure", () => {
|
||||
const result = assessBundleCompatibility({ version: "2.0", type: "full-config" }, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("invalidStructure");
|
||||
});
|
||||
});
|
||||
@@ -97,3 +97,22 @@ export const assessBundleCompatibility = (
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const parseAndValidateBundle = (
|
||||
content: string,
|
||||
currentHomarrVersion: string,
|
||||
): HomarrConfigBundle => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
throw new Error(compatibility.issues.join(" "));
|
||||
}
|
||||
|
||||
return bundle;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Declarative field definitions for entities in the config bundle.
|
||||
*
|
||||
* To add a new user preference:
|
||||
* 1. Add the column to the DB schema (migration)
|
||||
* 2. Add the field name to USER_DIRECT_FIELDS below
|
||||
* That's it — export, import, and schema all derive from this.
|
||||
*
|
||||
* To add a new FK reference on users:
|
||||
* 1. Add the column to the DB schema (migration)
|
||||
* 2. Add an entry to USER_REF_FIELDS below (bundleKey → dbColumn → refMap name)
|
||||
*/
|
||||
|
||||
/**
|
||||
* User fields copied directly between bundle and DB (no transformation).
|
||||
* Adding a new preference here automatically includes it in export AND import.
|
||||
*/
|
||||
export const USER_DIRECT_FIELDS = [
|
||||
"name",
|
||||
"email",
|
||||
"password",
|
||||
"provider",
|
||||
"colorScheme",
|
||||
"firstDayOfWeek",
|
||||
"openSearchInNewTab",
|
||||
"ddgBangs",
|
||||
"pingIconsEnabled",
|
||||
] as const;
|
||||
|
||||
export type UserDirectField = (typeof USER_DIRECT_FIELDS)[number];
|
||||
|
||||
/**
|
||||
* User FK reference fields: bundle uses `xxxRef`, DB uses `xxxId`.
|
||||
* Key = bundle field name (with "Ref" suffix)
|
||||
* Value = which ref map to use during import (matches the entity group name)
|
||||
*/
|
||||
export const USER_REF_FIELDS = {
|
||||
homeBoardRef: "boards",
|
||||
mobileHomeBoardRef: "boards",
|
||||
defaultSearchEngineRef: "searchEngines",
|
||||
} as const;
|
||||
|
||||
export type UserRefField = keyof typeof USER_REF_FIELDS;
|
||||
|
||||
/**
|
||||
* Derives the DB column name from a bundle ref field name.
|
||||
* "homeBoardRef" → "homeBoardId"
|
||||
*/
|
||||
export const refFieldToDbColumn = (refField: string): string =>
|
||||
refField.replace(/Ref$/, "Id");
|
||||
|
||||
/**
|
||||
* Group FK reference fields.
|
||||
*/
|
||||
export const GROUP_REF_FIELDS = {
|
||||
ownerRef: "users",
|
||||
homeBoardRef: "boards",
|
||||
mobileHomeBoardRef: "boards",
|
||||
} as const;
|
||||
@@ -3,9 +3,8 @@ import SuperJSON from "superjson";
|
||||
import { env } from "@homarr/common/env";
|
||||
import type { Database } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
@@ -13,33 +12,37 @@ import {
|
||||
integrationSecrets,
|
||||
searchEngines,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { CONFIG_BUNDLE_FORMAT_VERSION } from "../config-bundle-compat";
|
||||
import type { HomarrBundleApp, HomarrConfigBundle } from "../schema";
|
||||
import { USER_DIRECT_FIELDS, USER_REF_FIELDS } from "../entity-fields";
|
||||
import { groupByKey } from "../resolve-entities";
|
||||
import type { ConfigBundleUser, HomarrBundleApp, HomarrConfigBundle } from "../schema";
|
||||
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
|
||||
import { loadBoardGraphAsync } from "./load-board-graph";
|
||||
import { buildBoardSettings } from "./serialize-board";
|
||||
|
||||
const buildBoardSettings = (board: NonNullable<Awaited<ReturnType<typeof loadBoardGraphAsync>>>) => ({
|
||||
pageTitle: board.pageTitle,
|
||||
metaTitle: board.metaTitle,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
faviconImageUrl: board.faviconImageUrl,
|
||||
backgroundImageUrl: board.backgroundImageUrl,
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
customCss: board.customCss,
|
||||
iconColor: board.iconColor,
|
||||
itemRadius: board.itemRadius,
|
||||
disableStatus: board.disableStatus,
|
||||
isPublic: board.isPublic,
|
||||
});
|
||||
/**
|
||||
* Exports a user row using USER_DIRECT_FIELDS and USER_REF_FIELDS.
|
||||
* Adding a new preference to USER_DIRECT_FIELDS automatically includes it here.
|
||||
*/
|
||||
const exportUserRow = (user: Record<string, unknown>): ConfigBundleUser => {
|
||||
const bundle: Record<string, unknown> = { ref: user.id };
|
||||
|
||||
for (const field of USER_DIRECT_FIELDS) {
|
||||
bundle[field] = user[field] ?? null;
|
||||
}
|
||||
|
||||
for (const refField of Object.keys(USER_REF_FIELDS)) {
|
||||
const dbCol = refField.replace(/Ref$/, "Id");
|
||||
bundle[refField] = user[dbCol] ?? null;
|
||||
}
|
||||
|
||||
return bundle as unknown as ConfigBundleUser;
|
||||
};
|
||||
|
||||
export const exportFullConfigAsync = async (
|
||||
db: Database,
|
||||
@@ -55,20 +58,20 @@ export const exportFullConfigAsync = async (
|
||||
const allGroupPermissions = await db.select().from(groupPermissions);
|
||||
const allBoardGroupPermissions = await db.select().from(boardGroupPermissions);
|
||||
const allIntegrationGroupPermissions = await db.select().from(integrationGroupPermissions);
|
||||
const allUsers = await db.select().from(users);
|
||||
const allGroupMembers = await db.select().from(groupMembers);
|
||||
|
||||
const secretsByIntegration = new Map<string, { kind: IntegrationSecretKind; value: string }[]>();
|
||||
for (const secret of allSecrets) {
|
||||
const list = secretsByIntegration.get(secret.integrationId) ?? [];
|
||||
list.push({ kind: secret.kind, value: secret.value });
|
||||
secretsByIntegration.set(secret.integrationId, list);
|
||||
}
|
||||
const secretsByIntegration = groupByKey(allSecrets, (s) => s.integrationId);
|
||||
|
||||
const bundleIntegrations = allIntegrations.map((integration) => ({
|
||||
ref: integration.id,
|
||||
kind: integration.kind,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
secrets: secretsByIntegration.get(integration.id) ?? [],
|
||||
secrets: (secretsByIntegration.get(integration.id) ?? []).map((s) => ({
|
||||
kind: s.kind as IntegrationSecretKind,
|
||||
value: s.value,
|
||||
})),
|
||||
}));
|
||||
|
||||
const bundleApps: HomarrBundleApp[] = allApps.map((app) => ({
|
||||
@@ -115,6 +118,7 @@ export const exportFullConfigAsync = async (
|
||||
});
|
||||
|
||||
bundleBoards.push({
|
||||
ref: boardRow.id,
|
||||
name: boardGraph.name,
|
||||
settings: buildBoardSettings(boardGraph),
|
||||
layouts: boardGraph.layouts.map((layout) => ({
|
||||
@@ -162,36 +166,32 @@ export const exportFullConfigAsync = async (
|
||||
integrationRef: se.integrationId,
|
||||
}));
|
||||
|
||||
const permissionsByGroup = new Map<string, string[]>();
|
||||
for (const gp of allGroupPermissions) {
|
||||
const list = permissionsByGroup.get(gp.groupId) ?? [];
|
||||
list.push(gp.permission);
|
||||
permissionsByGroup.set(gp.groupId, list);
|
||||
}
|
||||
|
||||
const boardPermsByGroup = new Map<string, { boardRef: string; permission: string }[]>();
|
||||
for (const bp of allBoardGroupPermissions) {
|
||||
const list = boardPermsByGroup.get(bp.groupId) ?? [];
|
||||
list.push({ boardRef: bp.boardId, permission: bp.permission });
|
||||
boardPermsByGroup.set(bp.groupId, list);
|
||||
}
|
||||
|
||||
const integrationPermsByGroup = new Map<string, { integrationRef: string; permission: string }[]>();
|
||||
for (const ip of allIntegrationGroupPermissions) {
|
||||
const list = integrationPermsByGroup.get(ip.groupId) ?? [];
|
||||
list.push({ integrationRef: ip.integrationId, permission: ip.permission });
|
||||
integrationPermsByGroup.set(ip.groupId, list);
|
||||
}
|
||||
const permissionsByGroup = groupByKey(allGroupPermissions, (gp) => gp.groupId);
|
||||
const boardPermsByGroup = groupByKey(allBoardGroupPermissions, (bp) => bp.groupId);
|
||||
const integrationPermsByGroup = groupByKey(allIntegrationGroupPermissions, (ip) => ip.groupId);
|
||||
const membersByGroup = groupByKey(allGroupMembers, (gm) => gm.groupId);
|
||||
|
||||
const bundleGroups = allGroups.map((group) => ({
|
||||
ref: group.id,
|
||||
name: group.name,
|
||||
position: group.position,
|
||||
permissions: permissionsByGroup.get(group.id) ?? [],
|
||||
boardPermissions: boardPermsByGroup.get(group.id) ?? [],
|
||||
integrationPermissions: integrationPermsByGroup.get(group.id) ?? [],
|
||||
ownerRef: group.ownerId ?? null,
|
||||
homeBoardRef: group.homeBoardId ?? null,
|
||||
mobileHomeBoardRef: group.mobileHomeBoardId ?? null,
|
||||
permissions: (permissionsByGroup.get(group.id) ?? []).map((gp) => gp.permission),
|
||||
boardPermissions: (boardPermsByGroup.get(group.id) ?? []).map((bp) => ({
|
||||
boardRef: bp.boardId,
|
||||
permission: bp.permission,
|
||||
})),
|
||||
integrationPermissions: (integrationPermsByGroup.get(group.id) ?? []).map((ip) => ({
|
||||
integrationRef: ip.integrationId,
|
||||
permission: ip.permission,
|
||||
})),
|
||||
memberRefs: (membersByGroup.get(group.id) ?? []).map((gm) => gm.userId),
|
||||
}));
|
||||
|
||||
const bundleUsers = allUsers.map((user) => exportUserRow(user as unknown as Record<string, unknown>));
|
||||
|
||||
const bundle: HomarrConfigBundle = {
|
||||
version: CONFIG_BUNDLE_FORMAT_VERSION,
|
||||
type: "full-config",
|
||||
@@ -204,6 +204,7 @@ export const exportFullConfigAsync = async (
|
||||
serverSettings: settingsRecord,
|
||||
searchEngines: bundleSearchEngines,
|
||||
groups: bundleGroups,
|
||||
users: bundleUsers,
|
||||
};
|
||||
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
boards,
|
||||
groups,
|
||||
integrationSecrets,
|
||||
integrations,
|
||||
@@ -10,6 +8,7 @@ import {
|
||||
searchEngines,
|
||||
sections,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
|
||||
import type { ConfigExportPreview } from "../types";
|
||||
@@ -24,6 +23,7 @@ export const previewExportFullConfigAsync = async (db: Database): Promise<Config
|
||||
const allLayouts = await db.select().from(layouts);
|
||||
const allSearchEngines = await db.select().from(searchEngines);
|
||||
const allGroups = await db.select().from(groups);
|
||||
const allUsers = await db.select().from(users);
|
||||
const allServerSettings = await db.select().from(serverSettings);
|
||||
|
||||
return {
|
||||
@@ -36,7 +36,7 @@ export const previewExportFullConfigAsync = async (db: Database): Promise<Config
|
||||
layouts: allLayouts.length,
|
||||
searchEngines: allSearchEngines.length,
|
||||
groups: allGroups.length,
|
||||
users: allUsers.length,
|
||||
serverSettings: allServerSettings.length,
|
||||
// itemLayouts tracked implicitly via widgets; layouts count covers breakpoints
|
||||
};
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import type { HomarrBundle, HomarrBundleApp, HomarrBundleIntegration } from "../
|
||||
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
|
||||
import type { LoadedBoardGraph } from "./load-board-graph";
|
||||
|
||||
const buildBoardSettings = (board: LoadedBoardGraph) => ({
|
||||
export const buildBoardSettings = (board: LoadedBoardGraph) => ({
|
||||
pageTitle: board.pageTitle,
|
||||
metaTitle: board.metaTitle,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { parseServicesYaml } from "./parse-services-yaml";
|
||||
export { homepageWidgetMap, type HomepageWidgetMapping } from "./widget-type-map";
|
||||
export { homepageWidgetMap, type HomepageWidgetMapping } from "@homarr/definitions";
|
||||
export { prepareHomepageImport, resolveHomepageIconUrl, type PreparedHomepageImport } from "./prepare-homepage-import";
|
||||
export { importHomepageServicesAsync, type HomepageImportReport } from "./import";
|
||||
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
|
||||
@@ -56,7 +56,7 @@ describe("parseServicesYaml", () => {
|
||||
|
||||
const qbit = result.services.find((service) => service.name === "qBittorrent");
|
||||
expect(qbit?.group).toBe("Downloads");
|
||||
expect(qbit?.widgets[0]).toMatchObject({ type: "qbittorrent", username: "admin" });
|
||||
expect(qbit?.widgets[0]).toMatchObject({ type: "qbittorrent", fields: { username: "admin" } });
|
||||
});
|
||||
|
||||
test("flattens nested groups with separator", () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ const normalizeWidget = (widget: Record<string, unknown>): HomepageWidget => {
|
||||
type: String(type),
|
||||
...(typeof url === "string" ? { url } : {}),
|
||||
...(typeof key === "string" ? { key } : {}),
|
||||
...rest,
|
||||
fields: rest,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -13,17 +13,15 @@ import type {
|
||||
sections,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { emptySuperJSON, integrationDefs } from "@homarr/definitions";
|
||||
import { emptySuperJSON, homepageWidgetMap, integrationDefs } from "@homarr/definitions";
|
||||
|
||||
import { stringifyForDb } from "../utils";
|
||||
import type { HomepageService, HomepageWidget } from "./types";
|
||||
import { homepageWidgetMap } from "./widget-type-map";
|
||||
|
||||
const DASHBOARD_ICON_CDN = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg";
|
||||
const ENV_VAR_PATTERN = /^\$\{[^}]+\}$/;
|
||||
const COLUMN_COUNT = 10;
|
||||
const APP_ITEM_WIDTH = 1;
|
||||
const APP_ITEM_HEIGHT = 1;
|
||||
const WIDGET_WIDTH = 2;
|
||||
|
||||
export type PreparedHomepageImport = {
|
||||
apps: InferInsertModel<typeof apps>[];
|
||||
@@ -39,6 +37,16 @@ export type PreparedHomepageImport = {
|
||||
unmappedWidgetTypes: string[];
|
||||
};
|
||||
|
||||
type GridPosition = { x: number; y: number };
|
||||
|
||||
const advanceGrid = (grid: GridPosition, width: number): GridPosition => {
|
||||
const nextX = grid.x + width;
|
||||
if (nextX >= COLUMN_COUNT) {
|
||||
return { x: 0, y: grid.y + 1 };
|
||||
}
|
||||
return { x: nextX, y: grid.y };
|
||||
};
|
||||
|
||||
const isEnvVarReference = (value: string) => ENV_VAR_PATTERN.test(value.trim());
|
||||
|
||||
export const resolveHomepageIconUrl = (icon: string | undefined, serviceName: string): string => {
|
||||
@@ -69,10 +77,135 @@ const resolvePingUrl = (service: HomepageService): string | null => {
|
||||
};
|
||||
|
||||
const extractSecretValue = (widget: HomepageWidget, homepageField: string): string | undefined => {
|
||||
const value = widget[homepageField];
|
||||
const value = widget.fields[homepageField] ?? (homepageField === "key" ? widget.key : undefined);
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
};
|
||||
|
||||
const descriptionDisplayModes: Record<string, string> = {
|
||||
present: "tooltip",
|
||||
absent: "hidden",
|
||||
};
|
||||
|
||||
type WidgetProcessingContext = {
|
||||
service: HomepageService;
|
||||
appId: string;
|
||||
sectionId: string;
|
||||
boardId: string;
|
||||
layoutId: string;
|
||||
gridState: Map<string, GridPosition>;
|
||||
integrationRows: InferInsertModel<typeof integrations>[];
|
||||
integrationSecretRows: InferInsertModel<typeof integrationSecrets>[];
|
||||
itemRows: InferInsertModel<typeof items>[];
|
||||
itemLayoutRows: InferInsertModel<typeof itemLayouts>[];
|
||||
integrationItemRows: InferInsertModel<typeof integrationItems>[];
|
||||
warnings: string[];
|
||||
unmappedWidgetTypesSet: Set<string>;
|
||||
};
|
||||
|
||||
const processServiceWidgets = (ctx: WidgetProcessingContext) => {
|
||||
for (const widget of ctx.service.widgets) {
|
||||
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
|
||||
if (!mapping) {
|
||||
ctx.unmappedWidgetTypesSet.add(widget.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
const integrationId = createId();
|
||||
const integrationUrl =
|
||||
(typeof widget.url === "string" && widget.url.length > 0 ? widget.url : ctx.service.href) ?? "";
|
||||
|
||||
if (integrationUrl.length === 0) {
|
||||
ctx.warnings.push(`Skipped integration for "${ctx.service.name}" (${widget.type}): missing URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.integrationRows.push({
|
||||
id: integrationId,
|
||||
kind: mapping.integrationKind,
|
||||
name: ctx.service.name,
|
||||
url: integrationUrl,
|
||||
appId: ctx.appId,
|
||||
});
|
||||
|
||||
processSecrets(ctx, widget, mapping, integrationId);
|
||||
validateSecrets(ctx, mapping, integrationId);
|
||||
placeWidgetItem(ctx, mapping.widgetKind, integrationId);
|
||||
}
|
||||
};
|
||||
|
||||
const processSecrets = (
|
||||
ctx: WidgetProcessingContext,
|
||||
widget: HomepageWidget,
|
||||
mapping: { secretFieldMap: Record<string, IntegrationSecretKind>; integrationKind: string },
|
||||
integrationId: string,
|
||||
) => {
|
||||
for (const [homepageField, secretKind] of Object.entries(mapping.secretFieldMap)) {
|
||||
const rawValue = extractSecretValue(widget, homepageField);
|
||||
if (!rawValue) continue;
|
||||
|
||||
if (isEnvVarReference(rawValue)) {
|
||||
ctx.warnings.push(
|
||||
`Unresolved secret for "${ctx.service.name}" (${widget.type}): ${homepageField} references an environment variable`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.integrationSecretRows.push({
|
||||
integrationId,
|
||||
kind: secretKind as IntegrationSecretKind,
|
||||
value: encryptSecret(rawValue),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateSecrets = (
|
||||
ctx: WidgetProcessingContext,
|
||||
mapping: { integrationKind: string },
|
||||
integrationId: string,
|
||||
) => {
|
||||
const requiredSecretSets = integrationDefs[mapping.integrationKind as keyof typeof integrationDefs].secretKinds;
|
||||
const providedKinds = new Set(
|
||||
ctx.integrationSecretRows.filter((s) => s.integrationId === integrationId).map((s) => s.kind),
|
||||
);
|
||||
const hasRequiredSecrets = requiredSecretSets.some(
|
||||
(secretSet) => secretSet.length === 0 || secretSet.every((kind) => providedKinds.has(kind)),
|
||||
);
|
||||
|
||||
if (!hasRequiredSecrets && requiredSecretSets.every((set) => set.length > 0)) {
|
||||
ctx.warnings.push(`Integration "${ctx.service.name}" (${mapping.integrationKind}) may be missing required credentials`);
|
||||
}
|
||||
};
|
||||
|
||||
const placeWidgetItem = (ctx: WidgetProcessingContext, widgetKind: string, integrationId: string) => {
|
||||
const widgetItemId = createId();
|
||||
const grid = ctx.gridState.get(ctx.sectionId) ?? { x: 0, y: 0 };
|
||||
|
||||
ctx.itemRows.push({
|
||||
id: widgetItemId,
|
||||
boardId: ctx.boardId,
|
||||
kind: widgetKind as never,
|
||||
options: emptySuperJSON,
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
ctx.itemLayoutRows.push({
|
||||
itemId: widgetItemId,
|
||||
sectionId: ctx.sectionId,
|
||||
layoutId: ctx.layoutId,
|
||||
width: WIDGET_WIDTH,
|
||||
height: 1,
|
||||
xOffset: grid.x,
|
||||
yOffset: grid.y,
|
||||
});
|
||||
|
||||
ctx.gridState.set(ctx.sectionId, advanceGrid(grid, WIDGET_WIDTH));
|
||||
|
||||
ctx.integrationItemRows.push({
|
||||
itemId: widgetItemId,
|
||||
integrationId,
|
||||
});
|
||||
};
|
||||
|
||||
export const prepareHomepageImport = (
|
||||
services: HomepageService[],
|
||||
boardName: string,
|
||||
@@ -93,13 +226,7 @@ export const prepareHomepageImport = (
|
||||
};
|
||||
|
||||
const layoutRows: InferInsertModel<typeof layouts>[] = [
|
||||
{
|
||||
id: layoutId,
|
||||
boardId,
|
||||
name: "Base",
|
||||
columnCount: COLUMN_COUNT,
|
||||
breakpoint: 0,
|
||||
},
|
||||
{ id: layoutId, boardId, name: "Base", columnCount: COLUMN_COUNT, breakpoint: 0 },
|
||||
];
|
||||
|
||||
const groupNames = [...new Set(services.map((service) => service.group || "Services"))];
|
||||
@@ -111,7 +238,7 @@ export const prepareHomepageImport = (
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "category" as const,
|
||||
name: groupName.length > 0 ? groupName : "Services",
|
||||
name: groupName || "Services",
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
};
|
||||
@@ -123,8 +250,7 @@ export const prepareHomepageImport = (
|
||||
const itemRows: InferInsertModel<typeof items>[] = [];
|
||||
const itemLayoutRows: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const integrationItemRows: InferInsertModel<typeof integrationItems>[] = [];
|
||||
|
||||
const gridState = new Map<string, { x: number; y: number }>();
|
||||
const gridState = new Map<string, GridPosition>();
|
||||
|
||||
for (const service of services) {
|
||||
const groupKey = service.group || "Services";
|
||||
@@ -159,7 +285,7 @@ export const prepareHomepageImport = (
|
||||
openInNewTab: true,
|
||||
showTitle: true,
|
||||
layout: "column",
|
||||
descriptionDisplayMode: service.description ? "tooltip" : "hidden",
|
||||
descriptionDisplayMode: descriptionDisplayModes[service.description ? "present" : "absent"],
|
||||
pingEnabled: pingUrl !== null,
|
||||
}),
|
||||
advancedOptions: emptySuperJSON,
|
||||
@@ -169,118 +295,39 @@ export const prepareHomepageImport = (
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
width: APP_ITEM_WIDTH,
|
||||
height: APP_ITEM_HEIGHT,
|
||||
width: 1,
|
||||
height: 1,
|
||||
xOffset: grid.x,
|
||||
yOffset: grid.y,
|
||||
});
|
||||
|
||||
grid.x += APP_ITEM_WIDTH;
|
||||
if (grid.x >= COLUMN_COUNT) {
|
||||
grid.x = 0;
|
||||
grid.y += APP_ITEM_HEIGHT;
|
||||
}
|
||||
gridState.set(sectionId, grid);
|
||||
gridState.set(sectionId, advanceGrid(grid, 1));
|
||||
|
||||
if (!createIntegrations) {
|
||||
for (const widget of service.widgets) {
|
||||
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
|
||||
if (mapping === undefined || mapping === null) {
|
||||
if (!mapping) {
|
||||
unmappedWidgetTypesSet.add(widget.type);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const widget of service.widgets) {
|
||||
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
|
||||
if (mapping === undefined || mapping === null) {
|
||||
unmappedWidgetTypesSet.add(widget.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
const integrationId = createId();
|
||||
const integrationUrl =
|
||||
(typeof widget.url === "string" && widget.url.length > 0 ? widget.url : service.href) ?? "";
|
||||
|
||||
if (integrationUrl.length === 0) {
|
||||
warnings.push(`Skipped integration for "${service.name}" (${widget.type}): missing URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
integrationRows.push({
|
||||
id: integrationId,
|
||||
kind: mapping.integrationKind,
|
||||
name: service.name,
|
||||
url: integrationUrl,
|
||||
appId,
|
||||
});
|
||||
|
||||
for (const [homepageField, secretKind] of Object.entries(mapping.secretFieldMap)) {
|
||||
const rawValue = extractSecretValue(widget, homepageField);
|
||||
if (!rawValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEnvVarReference(rawValue)) {
|
||||
warnings.push(
|
||||
`Unresolved secret for "${service.name}" (${widget.type}): ${homepageField} references an environment variable`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
integrationSecretRows.push({
|
||||
integrationId,
|
||||
kind: secretKind as IntegrationSecretKind,
|
||||
value: encryptSecret(rawValue),
|
||||
});
|
||||
}
|
||||
|
||||
const requiredSecretSets = integrationDefs[mapping.integrationKind].secretKinds;
|
||||
const providedKinds = new Set(
|
||||
integrationSecretRows.filter((secret) => secret.integrationId === integrationId).map((secret) => secret.kind),
|
||||
);
|
||||
const hasRequiredSecrets = requiredSecretSets.some(
|
||||
(secretSet) => secretSet.length === 0 || secretSet.every((kind) => providedKinds.has(kind)),
|
||||
);
|
||||
|
||||
if (!hasRequiredSecrets && requiredSecretSets.every((secretSet) => secretSet.length > 0)) {
|
||||
warnings.push(`Integration "${service.name}" (${mapping.integrationKind}) may be missing required credentials`);
|
||||
}
|
||||
|
||||
const widgetItemId = createId();
|
||||
const widgetGrid = gridState.get(sectionId) ?? { x: 0, y: 0 };
|
||||
|
||||
itemRows.push({
|
||||
id: widgetItemId,
|
||||
boardId,
|
||||
kind: mapping.widgetKind,
|
||||
options: emptySuperJSON,
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
itemLayoutRows.push({
|
||||
itemId: widgetItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
width: 2,
|
||||
height: 1,
|
||||
xOffset: widgetGrid.x,
|
||||
yOffset: widgetGrid.y,
|
||||
});
|
||||
|
||||
widgetGrid.x += 2;
|
||||
if (widgetGrid.x >= COLUMN_COUNT) {
|
||||
widgetGrid.x = 0;
|
||||
widgetGrid.y += 1;
|
||||
}
|
||||
gridState.set(sectionId, widgetGrid);
|
||||
|
||||
integrationItemRows.push({
|
||||
itemId: widgetItemId,
|
||||
integrationId,
|
||||
});
|
||||
}
|
||||
processServiceWidgets({
|
||||
service,
|
||||
appId,
|
||||
sectionId,
|
||||
boardId,
|
||||
layoutId,
|
||||
gridState,
|
||||
integrationRows,
|
||||
integrationSecretRows,
|
||||
itemRows,
|
||||
itemLayoutRows,
|
||||
integrationItemRows,
|
||||
warnings,
|
||||
unmappedWidgetTypesSet,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface HomepageWidget {
|
||||
type: string;
|
||||
url?: string;
|
||||
key?: string;
|
||||
[key: string]: unknown;
|
||||
fields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ParseServicesYamlResult =
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { integrationDefs, integrationKinds } from "@homarr/definitions";
|
||||
|
||||
import { homepageWidgetMap } from "./widget-type-map";
|
||||
import { homepageWidgetMap, integrationDefs, integrationKinds } from "@homarr/definitions";
|
||||
|
||||
describe("homepageWidgetMap", () => {
|
||||
test("returns mapping for known homepage widget types", () => {
|
||||
|
||||
@@ -6,204 +6,305 @@ import type { Database } from "@homarr/db";
|
||||
import { eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
import {
|
||||
apps,
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
searchEngines,
|
||||
sectionLayouts,
|
||||
sections,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import type { apps } from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
|
||||
import { GROUP_REF_FIELDS, USER_DIRECT_FIELDS, USER_REF_FIELDS, refFieldToDbColumn } from "../entity-fields";
|
||||
import { resolveEntities } from "../resolve-entities";
|
||||
import type { HomarrBundleBoard, HomarrConfigBundle } from "../schema";
|
||||
import { replaceAppRefsInValue, stringifyForDb } from "../utils";
|
||||
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
|
||||
import { loadExistingEntitiesAsync } from "./load-existing-entities";
|
||||
|
||||
export type ConfigImportReport = {
|
||||
boards: number;
|
||||
apps: number;
|
||||
integrations: number;
|
||||
groups: number;
|
||||
users: number;
|
||||
searchEngines: number;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const mustGet = <T>(map: Map<string, T>, key: string): T => {
|
||||
const value = map.get(key);
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing map entry for key "${key}"`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const reEncryptSecret = (encryptedValue: string, sourceKeyHex: string): `${string}.${string}` => {
|
||||
const sourceKey = Buffer.from(sourceKeyHex, "hex");
|
||||
const decrypted = decryptSecretWithKey(encryptedValue as `${string}.${string}`, sourceKey);
|
||||
return encryptSecret(decrypted);
|
||||
};
|
||||
|
||||
export const importFullConfigAsync = async (
|
||||
db: Database,
|
||||
const resolveRef = (map: Map<string, string>, ref: string | null | undefined): string | null => {
|
||||
if (!ref) return null;
|
||||
return map.get(ref) ?? null;
|
||||
};
|
||||
|
||||
const resolveApps = (
|
||||
bundle: HomarrConfigBundle,
|
||||
creatorId: string | null,
|
||||
): Promise<ConfigImportReport> => {
|
||||
const warnings: string[] = [];
|
||||
existingApps: { id: string; name: string; iconUrl: string; href: string | null }[],
|
||||
) =>
|
||||
resolveEntities(bundle.apps, existingApps, {
|
||||
getRef: (app) => app.ref,
|
||||
match: (bundle, existing) =>
|
||||
existing.name === bundle.name && existing.iconUrl === bundle.iconUrl && existing.href === bundle.href,
|
||||
toInsertRow: (app, id): (typeof apps.$inferInsert) => ({
|
||||
id,
|
||||
name: app.name,
|
||||
iconUrl: app.iconUrl,
|
||||
href: app.href,
|
||||
description: app.description ?? null,
|
||||
pingUrl: app.pingUrl ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
const resolveIntegrations = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingIntegrations: { id: string; kind: string; name: string; url: string }[],
|
||||
) => {
|
||||
const result = resolveEntities(bundle.integrations, existingIntegrations, {
|
||||
getRef: (i) => i.ref,
|
||||
match: (bundle, existing) =>
|
||||
existing.kind === bundle.kind && existing.name === bundle.name && existing.url === bundle.url,
|
||||
toInsertRow: (integration, id): (typeof integrations.$inferInsert) => ({
|
||||
id,
|
||||
kind: integration.kind,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
}),
|
||||
});
|
||||
|
||||
const secretRows: (typeof integrationSecrets.$inferInsert)[] = [];
|
||||
const secretWarnings: string[] = [];
|
||||
const sourceKey = bundle.encryptionKey;
|
||||
|
||||
const existingApps = await db.query.apps.findMany();
|
||||
const existingIntegrations = await db.query.integrations.findMany();
|
||||
const existingBoards = await db.query.boards.findMany();
|
||||
const existingGroups = await db.select().from(groups);
|
||||
const existingSearchEngines = await db.select().from(searchEngines);
|
||||
|
||||
const existingBoardNames = new Set(existingBoards.map((b) => b.name));
|
||||
const existingGroupNames = new Set(existingGroups.map((g) => g.name));
|
||||
const existingSearchEngineShorts = new Set(existingSearchEngines.map((se) => se.short));
|
||||
|
||||
const appRefToId = new Map<string, string>();
|
||||
const appsToInsert: typeof apps.$inferInsert[] = [];
|
||||
|
||||
for (const bundleApp of bundle.apps) {
|
||||
const existing = existingApps.find(
|
||||
(a) => a.name === bundleApp.name && a.iconUrl === bundleApp.iconUrl && a.href === bundleApp.href,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
appRefToId.set(bundleApp.ref, existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = createId();
|
||||
appRefToId.set(bundleApp.ref, newId);
|
||||
appsToInsert.push({
|
||||
id: newId,
|
||||
name: bundleApp.name,
|
||||
iconUrl: bundleApp.iconUrl,
|
||||
href: bundleApp.href,
|
||||
description: bundleApp.description ?? null,
|
||||
pingUrl: bundleApp.pingUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const integrationRefToId = new Map<string, string>();
|
||||
const integrationsToInsert: typeof integrations.$inferInsert[] = [];
|
||||
const secretsToInsert: typeof integrationSecrets.$inferInsert[] = [];
|
||||
|
||||
for (const bundleIntegration of bundle.integrations) {
|
||||
const existing = existingIntegrations.find(
|
||||
(i) =>
|
||||
i.kind === bundleIntegration.kind &&
|
||||
i.name === bundleIntegration.name &&
|
||||
i.url === bundleIntegration.url,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
integrationRefToId.set(bundleIntegration.ref, existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = createId();
|
||||
integrationRefToId.set(bundleIntegration.ref, newId);
|
||||
integrationsToInsert.push({
|
||||
id: newId,
|
||||
kind: bundleIntegration.kind,
|
||||
name: bundleIntegration.name,
|
||||
url: bundleIntegration.url,
|
||||
});
|
||||
const newId = result.refToId.get(bundleIntegration.ref);
|
||||
if (!newId || existingIntegrations.some((e) => e.id === newId)) continue;
|
||||
|
||||
for (const secret of bundleIntegration.secrets) {
|
||||
try {
|
||||
const reEncrypted = reEncryptSecret(secret.value, sourceKey);
|
||||
secretsToInsert.push({
|
||||
secretRows.push({
|
||||
integrationId: newId,
|
||||
kind: secret.kind as IntegrationSecretKind,
|
||||
value: reEncrypted,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} catch {
|
||||
warnings.push(`Failed to re-encrypt secret "${secret.kind}" for integration "${bundleIntegration.name}"`);
|
||||
secretWarnings.push(`Failed to re-encrypt secret "${secret.kind}" for integration "${bundleIntegration.name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const groupRefToId = new Map<string, string>();
|
||||
const groupsToInsert: typeof groups.$inferInsert[] = [];
|
||||
const groupPermsToInsert: typeof groupPermissions.$inferInsert[] = [];
|
||||
const boardGroupPermsToInsert: typeof boardGroupPermissions.$inferInsert[] = [];
|
||||
const integrationGroupPermsToInsert: typeof integrationGroupPermissions.$inferInsert[] = [];
|
||||
return {
|
||||
...result,
|
||||
secretRows,
|
||||
warnings: [...result.warnings, ...secretWarnings],
|
||||
};
|
||||
};
|
||||
|
||||
const resolveBoards = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingBoards: { id: string; name: string }[],
|
||||
) => {
|
||||
const boardRefToId = new Map<string, string>();
|
||||
const boardsToCreate = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
const existingBoardNames = new Set(existingBoards.map((b) => b.name));
|
||||
|
||||
for (const bundleBoard of bundle.boards) {
|
||||
if (existingBoardNames.has(bundleBoard.name)) {
|
||||
const existing = existingBoards.find((b) => b.name === bundleBoard.name);
|
||||
if (existing) {
|
||||
boardRefToId.set(bundleBoard.name, existing.id);
|
||||
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, existing.id);
|
||||
}
|
||||
warnings.push(`Board "${bundleBoard.name}" already exists and was skipped`);
|
||||
continue;
|
||||
}
|
||||
const boardId = createId();
|
||||
boardRefToId.set(bundleBoard.name, boardId);
|
||||
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, boardId);
|
||||
boardsToCreate.add(bundleBoard.name);
|
||||
}
|
||||
|
||||
return { boardRefToId, boardsToCreate, warnings };
|
||||
};
|
||||
|
||||
const resolveSearchEngines = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingSearchEngines: { id: string; short: string }[],
|
||||
integrationRefToId: Map<string, string>,
|
||||
) => {
|
||||
const result = resolveEntities(bundle.searchEngines, existingSearchEngines, {
|
||||
getRef: (se) => se.ref,
|
||||
match: (bundle, existing) => existing.short === bundle.short,
|
||||
toInsertRow: (se, id): (typeof searchEngines.$inferInsert) => ({
|
||||
id,
|
||||
iconUrl: se.iconUrl,
|
||||
name: se.name,
|
||||
short: se.short,
|
||||
description: se.description ?? null,
|
||||
urlTemplate: se.urlTemplate ?? null,
|
||||
type: se.type as never,
|
||||
integrationId: resolveRef(integrationRefToId, se.integrationRef),
|
||||
}),
|
||||
skipMessage: (se) => `Search engine "${se.name}" (short: ${se.short}) already exists and was skipped`,
|
||||
});
|
||||
|
||||
for (const existing of existingSearchEngines) {
|
||||
result.refToId.set(existing.id, existing.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses USER_DIRECT_FIELDS and USER_REF_FIELDS to build user insert rows.
|
||||
* Adding a new user preference to USER_DIRECT_FIELDS automatically
|
||||
* includes it here — no other changes needed.
|
||||
*/
|
||||
const resolveUsers = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingUsers: { id: string; email: string | null }[],
|
||||
refMaps: Record<string, Map<string, string>>,
|
||||
) =>
|
||||
resolveEntities(bundle.users ?? [], existingUsers, {
|
||||
getRef: (u) => u.ref,
|
||||
match: (bundle, existing) => Boolean(bundle.email) && existing.email === bundle.email,
|
||||
toInsertRow: (bundleUser, id): (typeof users.$inferInsert) => {
|
||||
const row: Record<string, unknown> = { id };
|
||||
|
||||
for (const field of USER_DIRECT_FIELDS) {
|
||||
row[field] = (bundleUser as Record<string, unknown>)[field] ?? null;
|
||||
}
|
||||
|
||||
for (const [refField, mapName] of Object.entries(USER_REF_FIELDS)) {
|
||||
const dbCol = refFieldToDbColumn(refField);
|
||||
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleUser as Record<string, unknown>)[refField] as string);
|
||||
}
|
||||
|
||||
return row as typeof users.$inferInsert;
|
||||
},
|
||||
});
|
||||
|
||||
const resolveGroups = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingGroups: { id: string; name: string }[],
|
||||
existingMembershipKeys: Set<string>,
|
||||
refMaps: Record<string, Map<string, string>>,
|
||||
) => {
|
||||
const result = resolveEntities(bundle.groups, existingGroups, {
|
||||
getRef: (g) => g.ref,
|
||||
match: (bundle, existing) => existing.name === bundle.name,
|
||||
toInsertRow: (bundleGroup, id): (typeof groups.$inferInsert) => {
|
||||
const row: Record<string, unknown> = { id, name: bundleGroup.name, position: bundleGroup.position };
|
||||
|
||||
for (const [refField, mapName] of Object.entries(GROUP_REF_FIELDS)) {
|
||||
const dbCol = refFieldToDbColumn(refField);
|
||||
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleGroup as Record<string, unknown>)[refField] as string);
|
||||
}
|
||||
|
||||
return row as typeof groups.$inferInsert;
|
||||
},
|
||||
skipMessage: (g) => `Group "${g.name}" already exists and was skipped`,
|
||||
});
|
||||
|
||||
const groupPermRows: (typeof groupPermissions.$inferInsert)[] = [];
|
||||
const boardGroupPermRows: (typeof boardGroupPermissions.$inferInsert)[] = [];
|
||||
const integrationGroupPermRows: (typeof integrationGroupPermissions.$inferInsert)[] = [];
|
||||
const memberRows: (typeof groupMembers.$inferInsert)[] = [];
|
||||
const extraWarnings: string[] = [];
|
||||
|
||||
for (const bundleGroup of bundle.groups) {
|
||||
const groupId = result.refToId.get(bundleGroup.ref);
|
||||
if (!groupId || existingGroups.some((g) => g.id === groupId)) continue;
|
||||
|
||||
for (const perm of bundleGroup.permissions) {
|
||||
groupPermRows.push({ groupId, permission: perm as never });
|
||||
}
|
||||
}
|
||||
|
||||
for (const bundleGroup of bundle.groups) {
|
||||
if (existingGroupNames.has(bundleGroup.name)) {
|
||||
const existing = existingGroups.find((g) => g.name === bundleGroup.name);
|
||||
if (existing) {
|
||||
groupRefToId.set(bundleGroup.ref, existing.id);
|
||||
const groupId = result.refToId.get(bundleGroup.ref);
|
||||
if (!groupId) continue;
|
||||
|
||||
for (const bp of bundleGroup.boardPermissions) {
|
||||
const resolvedBoardId = resolveRef(refMaps.boards!, bp.boardRef);
|
||||
if (!resolvedBoardId) {
|
||||
extraWarnings.push(`Board permission for group "${bundleGroup.name}" references unknown board, skipped`);
|
||||
continue;
|
||||
}
|
||||
warnings.push(`Group "${bundleGroup.name}" already exists and was skipped`);
|
||||
continue;
|
||||
boardGroupPermRows.push({ groupId, boardId: resolvedBoardId, permission: bp.permission as never });
|
||||
}
|
||||
|
||||
const newId = createId();
|
||||
groupRefToId.set(bundleGroup.ref, newId);
|
||||
groupsToInsert.push({
|
||||
id: newId,
|
||||
name: bundleGroup.name,
|
||||
position: bundleGroup.position,
|
||||
});
|
||||
for (const ip of bundleGroup.integrationPermissions) {
|
||||
const resolvedIntegrationId = resolveRef(refMaps.integrations!, ip.integrationRef);
|
||||
if (resolvedIntegrationId) {
|
||||
integrationGroupPermRows.push({ groupId, integrationId: resolvedIntegrationId, permission: ip.permission as never });
|
||||
}
|
||||
}
|
||||
|
||||
for (const perm of bundleGroup.permissions) {
|
||||
groupPermsToInsert.push({
|
||||
groupId: newId,
|
||||
permission: perm as never,
|
||||
});
|
||||
for (const memberRef of bundleGroup.memberRefs ?? []) {
|
||||
const userId = refMaps.users!.get(memberRef);
|
||||
if (!userId) continue;
|
||||
const key = `${groupId}:${userId}`;
|
||||
if (existingMembershipKeys.has(key)) continue;
|
||||
memberRows.push({ groupId, userId });
|
||||
}
|
||||
}
|
||||
|
||||
const insertCollection = createDbInsertCollectionForTransaction([
|
||||
"apps",
|
||||
"integrations",
|
||||
"integrationSecrets",
|
||||
"boards",
|
||||
"layouts",
|
||||
"sections",
|
||||
"sectionLayouts",
|
||||
"items",
|
||||
"itemLayouts",
|
||||
"integrationItems",
|
||||
"searchEngines",
|
||||
"groups",
|
||||
"groupPermissions",
|
||||
"boardGroupPermissions",
|
||||
"integrationGroupPermissions",
|
||||
]);
|
||||
return {
|
||||
...result,
|
||||
groupPermRows,
|
||||
boardGroupPermRows,
|
||||
integrationGroupPermRows,
|
||||
memberRows,
|
||||
warnings: [...result.warnings, ...extraWarnings],
|
||||
};
|
||||
};
|
||||
|
||||
insertCollection.apps.push(...appsToInsert);
|
||||
insertCollection.integrations.push(...integrationsToInsert);
|
||||
insertCollection.integrationSecrets.push(...secretsToInsert);
|
||||
insertCollection.groups.push(...groupsToInsert);
|
||||
insertCollection.groupPermissions.push(...groupPermsToInsert);
|
||||
const resolveServerSettings = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingSettings: { settingKey: string; value: string }[],
|
||||
) => {
|
||||
const existingKeys = new Set(existingSettings.map((s) => s.settingKey));
|
||||
const toInsert: (typeof serverSettings.$inferInsert)[] = [];
|
||||
const toUpdate: { key: string; value: string }[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(bundle.serverSettings)) {
|
||||
const serialized = SuperJSON.stringify(value);
|
||||
if (existingKeys.has(key)) {
|
||||
toUpdate.push({ key, value: serialized });
|
||||
} else {
|
||||
toInsert.push({ settingKey: key, value: serialized });
|
||||
}
|
||||
}
|
||||
|
||||
return { toInsert, toUpdate };
|
||||
};
|
||||
|
||||
const buildBoardInserts = (
|
||||
bundle: HomarrConfigBundle,
|
||||
boardsToCreate: Set<string>,
|
||||
boardRefToId: Map<string, string>,
|
||||
appRefToId: Map<string, string>,
|
||||
integrationRefToId: Map<string, string>,
|
||||
creatorId: string | null,
|
||||
insertCollection: Pick<ReturnType<typeof createDbInsertCollectionForTransaction>, "boards" | "layouts" | "sections" | "sectionLayouts" | "items" | "itemLayouts" | "integrationItems">,
|
||||
) => {
|
||||
let boardsCreated = 0;
|
||||
|
||||
for (const bundleBoard of bundle.boards) {
|
||||
if (!boardsToCreate.has(bundleBoard.name)) continue;
|
||||
const boardId = boardRefToId.get(bundleBoard.name);
|
||||
if (!boardId) continue;
|
||||
|
||||
@@ -213,28 +314,7 @@ export const importFullConfigAsync = async (
|
||||
const sectionRefToId = new Map(sourceBoard.sections.map((s) => [s.ref, createId()]));
|
||||
const itemRefToId = new Map(sourceBoard.items.map((i) => [i.ref, createId()]));
|
||||
|
||||
insertCollection.boards.push({
|
||||
id: boardId,
|
||||
name: sourceBoard.name,
|
||||
creatorId,
|
||||
isPublic: sourceBoard.settings.isPublic ?? false,
|
||||
pageTitle: sourceBoard.settings.pageTitle ?? null,
|
||||
metaTitle: sourceBoard.settings.metaTitle ?? null,
|
||||
logoImageUrl: sourceBoard.settings.logoImageUrl ?? null,
|
||||
faviconImageUrl: sourceBoard.settings.faviconImageUrl ?? null,
|
||||
backgroundImageUrl: sourceBoard.settings.backgroundImageUrl ?? null,
|
||||
backgroundImageAttachment:
|
||||
sourceBoard.settings.backgroundImageAttachment ?? backgroundImageAttachments.defaultValue,
|
||||
backgroundImageRepeat: sourceBoard.settings.backgroundImageRepeat ?? backgroundImageRepeats.defaultValue,
|
||||
backgroundImageSize: sourceBoard.settings.backgroundImageSize ?? backgroundImageSizes.defaultValue,
|
||||
primaryColor: sourceBoard.settings.primaryColor,
|
||||
secondaryColor: sourceBoard.settings.secondaryColor,
|
||||
opacity: sourceBoard.settings.opacity,
|
||||
customCss: sourceBoard.settings.customCss ?? null,
|
||||
iconColor: sourceBoard.settings.iconColor ?? null,
|
||||
itemRadius: sourceBoard.settings.itemRadius,
|
||||
disableStatus: sourceBoard.settings.disableStatus ?? false,
|
||||
});
|
||||
insertCollection.boards.push(buildBoardInsertRow(boardId, sourceBoard.name, creatorId, sourceBoard.settings));
|
||||
|
||||
for (const layout of sourceBoard.layouts) {
|
||||
insertCollection.layouts.push({
|
||||
@@ -304,89 +384,123 @@ export const importFullConfigAsync = async (
|
||||
}
|
||||
}
|
||||
|
||||
for (const bundleSe of bundle.searchEngines) {
|
||||
if (existingSearchEngineShorts.has(bundleSe.short)) {
|
||||
warnings.push(`Search engine "${bundleSe.name}" (short: ${bundleSe.short}) already exists and was skipped`);
|
||||
continue;
|
||||
}
|
||||
return boardsCreated;
|
||||
};
|
||||
|
||||
insertCollection.searchEngines.push({
|
||||
id: createId(),
|
||||
iconUrl: bundleSe.iconUrl,
|
||||
name: bundleSe.name,
|
||||
short: bundleSe.short,
|
||||
description: bundleSe.description ?? null,
|
||||
urlTemplate: bundleSe.urlTemplate ?? null,
|
||||
type: bundleSe.type as never,
|
||||
integrationId: bundleSe.integrationRef ? (integrationRefToId.get(bundleSe.integrationRef) ?? null) : null,
|
||||
});
|
||||
}
|
||||
export const importFullConfigAsync = async (
|
||||
db: Database,
|
||||
bundle: HomarrConfigBundle,
|
||||
creatorId: string | null,
|
||||
): Promise<ConfigImportReport> => {
|
||||
const existing = await loadExistingEntitiesAsync(db);
|
||||
const existingMembershipKeys = new Set(existing.groupMembers.map((gm) => `${gm.groupId}:${gm.userId}`));
|
||||
|
||||
for (const bundleGroup of bundle.groups) {
|
||||
const groupId = groupRefToId.get(bundleGroup.ref);
|
||||
if (!groupId) continue;
|
||||
const resolvedApps = resolveApps(bundle, existing.apps);
|
||||
const resolvedIntegrations = resolveIntegrations(bundle, existing.integrations);
|
||||
const resolvedBoardsResult = resolveBoards(bundle, existing.boards);
|
||||
const resolvedSearchEngines = resolveSearchEngines(bundle, existing.searchEngines, resolvedIntegrations.refToId);
|
||||
|
||||
for (const bp of bundleGroup.boardPermissions) {
|
||||
const resolvedBoardId = boardRefToId.get(bp.boardRef) ?? bp.boardRef;
|
||||
boardGroupPermsToInsert.push({
|
||||
groupId,
|
||||
boardId: resolvedBoardId,
|
||||
permission: bp.permission as never,
|
||||
});
|
||||
}
|
||||
const refMaps: Record<string, Map<string, string>> = {
|
||||
boards: resolvedBoardsResult.boardRefToId,
|
||||
searchEngines: resolvedSearchEngines.refToId,
|
||||
integrations: resolvedIntegrations.refToId,
|
||||
users: new Map<string, string>(),
|
||||
};
|
||||
|
||||
for (const ip of bundleGroup.integrationPermissions) {
|
||||
const resolvedIntegrationId = integrationRefToId.get(ip.integrationRef);
|
||||
if (resolvedIntegrationId) {
|
||||
integrationGroupPermsToInsert.push({
|
||||
groupId,
|
||||
integrationId: resolvedIntegrationId,
|
||||
permission: ip.permission as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolvedUsers = resolveUsers(bundle, existing.users, refMaps);
|
||||
refMaps.users = resolvedUsers.refToId;
|
||||
|
||||
insertCollection.boardGroupPermissions.push(...boardGroupPermsToInsert);
|
||||
insertCollection.integrationGroupPermissions.push(...integrationGroupPermsToInsert);
|
||||
const resolvedGroups = resolveGroups(bundle, existing.groups, existingMembershipKeys, refMaps);
|
||||
|
||||
const resolvedSettings = resolveServerSettings(bundle, existing.serverSettings);
|
||||
|
||||
const insertCollection = createDbInsertCollectionForTransaction([
|
||||
"apps",
|
||||
"integrations",
|
||||
"integrationSecrets",
|
||||
"boards",
|
||||
"layouts",
|
||||
"sections",
|
||||
"sectionLayouts",
|
||||
"items",
|
||||
"itemLayouts",
|
||||
"integrationItems",
|
||||
"searchEngines",
|
||||
"users",
|
||||
"groups",
|
||||
"groupPermissions",
|
||||
"groupMembers",
|
||||
"boardGroupPermissions",
|
||||
"integrationGroupPermissions",
|
||||
]);
|
||||
|
||||
insertCollection.apps.push(...resolvedApps.rows);
|
||||
insertCollection.integrations.push(...resolvedIntegrations.rows);
|
||||
insertCollection.integrationSecrets.push(...resolvedIntegrations.secretRows);
|
||||
insertCollection.searchEngines.push(...resolvedSearchEngines.rows);
|
||||
insertCollection.users.push(...resolvedUsers.rows);
|
||||
insertCollection.groups.push(...resolvedGroups.rows);
|
||||
insertCollection.groupPermissions.push(...resolvedGroups.groupPermRows);
|
||||
insertCollection.groupMembers.push(...resolvedGroups.memberRows);
|
||||
insertCollection.boardGroupPermissions.push(...resolvedGroups.boardGroupPermRows);
|
||||
insertCollection.integrationGroupPermissions.push(...resolvedGroups.integrationGroupPermRows);
|
||||
|
||||
const boardsCreated = buildBoardInserts(
|
||||
bundle,
|
||||
resolvedBoardsResult.boardsToCreate,
|
||||
resolvedBoardsResult.boardRefToId,
|
||||
resolvedApps.refToId,
|
||||
resolvedIntegrations.refToId,
|
||||
creatorId,
|
||||
insertCollection,
|
||||
);
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(innerDb) {
|
||||
await insertCollection.insertAllAsync(innerDb);
|
||||
async handleAsync(innerDb, dbSchema) {
|
||||
await innerDb.transaction(async (trx) => {
|
||||
await insertCollection.insertAllAsync(trx);
|
||||
if (resolvedSettings.toInsert.length > 0) {
|
||||
await trx.insert(dbSchema.serverSettings).values(resolvedSettings.toInsert as never);
|
||||
}
|
||||
for (const setting of resolvedSettings.toUpdate) {
|
||||
await trx
|
||||
.update(dbSchema.serverSettings)
|
||||
.set({ value: setting.value })
|
||||
.where(eq(dbSchema.serverSettings.settingKey, setting.key));
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(innerDb) {
|
||||
insertCollection.insertAll(innerDb);
|
||||
innerDb.transaction((trx) => {
|
||||
insertCollection.insertAll(trx);
|
||||
if (resolvedSettings.toInsert.length > 0) {
|
||||
trx.insert(serverSettings).values(resolvedSettings.toInsert as never).run();
|
||||
}
|
||||
for (const setting of resolvedSettings.toUpdate) {
|
||||
trx
|
||||
.update(serverSettings)
|
||||
.set({ value: setting.value })
|
||||
.where(eq(serverSettings.settingKey, setting.key))
|
||||
.run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
for (const [key, value] of Object.entries(bundle.serverSettings)) {
|
||||
try {
|
||||
const existing = await db.query.serverSettings.findFirst({
|
||||
where: (table, { eq }) => eq(table.settingKey, key),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(serverSettings)
|
||||
.set({ value: SuperJSON.stringify(value) })
|
||||
.where(eq(serverSettings.settingKey, key));
|
||||
} else {
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: key,
|
||||
value: SuperJSON.stringify(value),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
warnings.push(`Failed to import server setting "${key}"`);
|
||||
}
|
||||
}
|
||||
const warnings = [
|
||||
...resolvedIntegrations.warnings,
|
||||
...resolvedBoardsResult.warnings,
|
||||
...resolvedSearchEngines.warnings,
|
||||
...resolvedGroups.warnings,
|
||||
];
|
||||
|
||||
return {
|
||||
boards: boardsCreated,
|
||||
apps: appsToInsert.length,
|
||||
integrations: integrationsToInsert.length,
|
||||
groups: groupsToInsert.length,
|
||||
searchEngines: bundle.searchEngines.length - warnings.filter((w) => w.includes("Search engine")).length,
|
||||
apps: resolvedApps.rows.length,
|
||||
integrations: resolvedIntegrations.rows.length,
|
||||
groups: resolvedGroups.rows.length,
|
||||
users: resolvedUsers.rows.length,
|
||||
searchEngines: resolvedSearchEngines.rows.length,
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Database, InferSelectModel } from "@homarr/db";
|
||||
import type { groupMembers, groups, searchEngines, serverSettings, users } from "@homarr/db/schema";
|
||||
import { groupMembers as groupMembersTable, groups as groupsTable, searchEngines as searchEnginesTable, serverSettings as serverSettingsTable, users as usersTable } from "@homarr/db/schema";
|
||||
|
||||
export type ExistingEntities = {
|
||||
apps: Awaited<ReturnType<Database["query"]["apps"]["findMany"]>>;
|
||||
integrations: Awaited<ReturnType<Database["query"]["integrations"]["findMany"]>>;
|
||||
boards: Awaited<ReturnType<Database["query"]["boards"]["findMany"]>>;
|
||||
groups: InferSelectModel<typeof groups>[];
|
||||
searchEngines: InferSelectModel<typeof searchEngines>[];
|
||||
users: InferSelectModel<typeof users>[];
|
||||
groupMembers: InferSelectModel<typeof groupMembers>[];
|
||||
serverSettings: InferSelectModel<typeof serverSettings>[];
|
||||
};
|
||||
|
||||
export const loadExistingEntitiesAsync = async (db: Database): Promise<ExistingEntities> => {
|
||||
const [appsResult, integrationsResult, boardsResult, groupsResult, searchEnginesResult, usersResult, groupMembersResult, serverSettingsResult] =
|
||||
await Promise.all([
|
||||
db.query.apps.findMany(),
|
||||
db.query.integrations.findMany(),
|
||||
db.query.boards.findMany(),
|
||||
db.select().from(groupsTable),
|
||||
db.select().from(searchEnginesTable),
|
||||
db.select().from(usersTable),
|
||||
db.select().from(groupMembersTable),
|
||||
db.select().from(serverSettingsTable),
|
||||
]);
|
||||
|
||||
return {
|
||||
apps: appsResult,
|
||||
integrations: integrationsResult,
|
||||
boards: boardsResult,
|
||||
groups: groupsResult,
|
||||
searchEngines: searchEnginesResult,
|
||||
users: usersResult,
|
||||
groupMembers: groupMembersResult,
|
||||
serverSettings: serverSettingsResult,
|
||||
};
|
||||
};
|
||||
@@ -1,27 +1,38 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { groups, searchEngines } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { assessBundleCompatibility } from "../config-bundle-compat";
|
||||
import type { HomarrConfigBundle } from "../schema";
|
||||
import type { ConfigEntityCounts, ConfigImportPreview } from "../types";
|
||||
import { emptyCounts } from "../types";
|
||||
import { loadExistingEntitiesAsync } from "./load-existing-entities";
|
||||
import { doAppsMatch } from "./match-apps";
|
||||
|
||||
const emptyCounts = (): ConfigEntityCounts => ({
|
||||
boards: 0,
|
||||
apps: 0,
|
||||
integrations: 0,
|
||||
secrets: 0,
|
||||
widgets: 0,
|
||||
sections: 0,
|
||||
layouts: 0,
|
||||
searchEngines: 0,
|
||||
groups: 0,
|
||||
serverSettings: 0,
|
||||
const emptyPreview = (
|
||||
compatibility: ConfigImportPreview["compatibility"],
|
||||
meta: ConfigImportPreview["meta"],
|
||||
warnings: string[] = [],
|
||||
): ConfigImportPreview => ({
|
||||
compatibility,
|
||||
meta,
|
||||
toCreate: emptyCounts(),
|
||||
toReuse: { apps: 0, integrations: 0, users: 0 },
|
||||
toSkip: { boards: 0, groups: 0, searchEngines: 0 },
|
||||
toUpdate: { serverSettings: 0 },
|
||||
warnings,
|
||||
});
|
||||
|
||||
const extractMetaFromUnknown = (parsed: unknown): ConfigImportPreview["meta"] => {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
return {
|
||||
exportedAt: typeof obj.exportedAt === "string" ? obj.exportedAt : "",
|
||||
homarrVersion: typeof obj.homarrVersion === "string" ? obj.homarrVersion : "",
|
||||
bundleVersion: typeof obj.version === "string" ? obj.version : "",
|
||||
};
|
||||
};
|
||||
|
||||
const integrationMatches = (
|
||||
bundleIntegration: HomarrConfigBundle["integrations"][number],
|
||||
existing: { kind: IntegrationKind; name: string; url: string },
|
||||
@@ -30,88 +41,49 @@ const integrationMatches = (
|
||||
bundleIntegration.name === existing.name &&
|
||||
bundleIntegration.url === existing.url;
|
||||
|
||||
const countBoardEntities = (board: HomarrConfigBundle["boards"][number]) => {
|
||||
const widgetCount = board.items.length;
|
||||
const sectionCount = board.sections.length;
|
||||
const layoutCount = board.layouts.length;
|
||||
return { widgetCount, sectionCount, layoutCount };
|
||||
};
|
||||
|
||||
export const previewImportFullConfigAsync = async (
|
||||
db: Database,
|
||||
content: string,
|
||||
currentHomarrVersion: string,
|
||||
): Promise<ConfigImportPreview> => {
|
||||
const warnings: string[] = [];
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return {
|
||||
compatibility: {
|
||||
status: "invalidStructure",
|
||||
bundleVersion: null,
|
||||
bundleHomarrVersion: null,
|
||||
currentHomarrVersion,
|
||||
issues: ["File is not valid JSON"],
|
||||
},
|
||||
meta: { exportedAt: "", homarrVersion: "", bundleVersion: "" },
|
||||
toCreate: emptyCounts(),
|
||||
toReuse: { apps: 0, integrations: 0 },
|
||||
toSkip: { boards: 0, groups: 0, searchEngines: 0 },
|
||||
toUpdate: { serverSettings: 0 },
|
||||
warnings: [],
|
||||
};
|
||||
return emptyPreview(
|
||||
{ status: "invalidStructure", bundleVersion: null, bundleHomarrVersion: null, currentHomarrVersion, issues: ["File is not valid JSON"] },
|
||||
{ exportedAt: "", homarrVersion: "", bundleVersion: "" },
|
||||
);
|
||||
}
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
|
||||
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
return {
|
||||
compatibility,
|
||||
meta: {
|
||||
exportedAt: typeof (parsed as Record<string, unknown>).exportedAt === "string" ? ((parsed as Record<string, unknown>).exportedAt as string) : "",
|
||||
homarrVersion:
|
||||
typeof (parsed as Record<string, unknown>).homarrVersion === "string"
|
||||
? ((parsed as Record<string, unknown>).homarrVersion as string)
|
||||
: "",
|
||||
bundleVersion:
|
||||
typeof (parsed as Record<string, unknown>).version === "string"
|
||||
? ((parsed as Record<string, unknown>).version as string)
|
||||
: "",
|
||||
},
|
||||
toCreate: emptyCounts(),
|
||||
toReuse: { apps: 0, integrations: 0 },
|
||||
toSkip: { boards: 0, groups: 0, searchEngines: 0 },
|
||||
toUpdate: { serverSettings: 0 },
|
||||
warnings: compatibility.issues,
|
||||
};
|
||||
return emptyPreview(compatibility, extractMetaFromUnknown(parsed), compatibility.issues);
|
||||
}
|
||||
|
||||
const existingApps = await db.query.apps.findMany();
|
||||
const existingIntegrations = await db.query.integrations.findMany();
|
||||
const existingBoards = await db.query.boards.findMany();
|
||||
const existingGroups = await db.select().from(groups);
|
||||
const existingSearchEngines = await db.select().from(searchEngines);
|
||||
const existing = await loadExistingEntitiesAsync(db);
|
||||
const existingSettingsByKey = new Map(
|
||||
(await db.query.serverSettings.findMany()).map((setting) => [setting.settingKey, setting.value] as const),
|
||||
existing.serverSettings.map((setting) => [setting.settingKey, setting.value] as const),
|
||||
);
|
||||
|
||||
const existingBoardNames = new Set(existingBoards.map((board) => board.name));
|
||||
const existingGroupNames = new Set(existingGroups.map((group) => group.name));
|
||||
const existingSearchEngineShorts = new Set(existingSearchEngines.map((engine) => engine.short));
|
||||
const existingBoardNames = new Set(existing.boards.map((b) => b.name));
|
||||
const existingGroupNames = new Set(existing.groups.map((g) => g.name));
|
||||
const existingSearchEngineShorts = new Set(existing.searchEngines.map((se) => se.short));
|
||||
const existingUserEmails = new Set(existing.users.filter((u) => u.email).map((u) => u.email!));
|
||||
|
||||
const toCreate = emptyCounts();
|
||||
const warnings: string[] = [];
|
||||
let reuseApps = 0;
|
||||
let reuseIntegrations = 0;
|
||||
let reuseUsers = 0;
|
||||
let skipBoards = 0;
|
||||
let skipGroups = 0;
|
||||
let skipSearchEngines = 0;
|
||||
let updateServerSettings = 0;
|
||||
|
||||
for (const bundleApp of bundle.apps) {
|
||||
const existing = existingApps.find((app) =>
|
||||
const matched = existing.apps.find((app) =>
|
||||
doAppsMatch(app, {
|
||||
name: bundleApp.name,
|
||||
iconUrl: bundleApp.iconUrl,
|
||||
@@ -120,7 +92,7 @@ export const previewImportFullConfigAsync = async (
|
||||
pingUrl: bundleApp.pingUrl ?? null,
|
||||
}),
|
||||
);
|
||||
if (existing) {
|
||||
if (matched) {
|
||||
reuseApps += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -128,8 +100,8 @@ export const previewImportFullConfigAsync = async (
|
||||
}
|
||||
|
||||
for (const bundleIntegration of bundle.integrations) {
|
||||
const existing = existingIntegrations.find((integration) => integrationMatches(bundleIntegration, integration));
|
||||
if (existing) {
|
||||
const matched = existing.integrations.find((i) => integrationMatches(bundleIntegration, i));
|
||||
if (matched) {
|
||||
reuseIntegrations += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -144,10 +116,9 @@ export const previewImportFullConfigAsync = async (
|
||||
continue;
|
||||
}
|
||||
toCreate.boards += 1;
|
||||
const counts = countBoardEntities(bundleBoard);
|
||||
toCreate.widgets += counts.widgetCount;
|
||||
toCreate.sections += counts.sectionCount;
|
||||
toCreate.layouts += counts.layoutCount;
|
||||
toCreate.widgets += bundleBoard.items.length;
|
||||
toCreate.sections += bundleBoard.sections.length;
|
||||
toCreate.layouts += bundleBoard.layouts.length;
|
||||
}
|
||||
|
||||
for (const bundleGroup of bundle.groups) {
|
||||
@@ -168,6 +139,14 @@ export const previewImportFullConfigAsync = async (
|
||||
toCreate.searchEngines += 1;
|
||||
}
|
||||
|
||||
for (const bundleUser of bundle.users ?? []) {
|
||||
if (bundleUser.email && existingUserEmails.has(bundleUser.email)) {
|
||||
reuseUsers += 1;
|
||||
continue;
|
||||
}
|
||||
toCreate.users += 1;
|
||||
}
|
||||
|
||||
for (const [settingKey, value] of Object.entries(bundle.serverSettings)) {
|
||||
const serialized = SuperJSON.stringify(value);
|
||||
const existingValue = existingSettingsByKey.get(settingKey);
|
||||
@@ -188,7 +167,7 @@ export const previewImportFullConfigAsync = async (
|
||||
bundleVersion: bundle.version,
|
||||
},
|
||||
toCreate,
|
||||
toReuse: { apps: reuseApps, integrations: reuseIntegrations },
|
||||
toReuse: { apps: reuseApps, integrations: reuseIntegrations, users: reuseUsers },
|
||||
toSkip: { boards: skipBoards, groups: skipGroups, searchEngines: skipSearchEngines },
|
||||
toUpdate: { serverSettings: updateServerSettings },
|
||||
warnings,
|
||||
|
||||
@@ -11,20 +11,11 @@ import type {
|
||||
sectionLayouts,
|
||||
sections,
|
||||
} from "@homarr/db/schema";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
|
||||
import type { HomarrBundle, HomarrBundleBoard } from "../schema";
|
||||
import { replaceAppRefsInValue, stringifyForDb } from "../utils";
|
||||
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
|
||||
import { doAppsMatch } from "./match-apps";
|
||||
|
||||
const mustGet = <T>(map: Map<string, T>, key: string): T => {
|
||||
const value = map.get(key);
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing map entry for key "${key}"`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export type PreparedBundleImport = {
|
||||
apps: InferInsertModel<typeof apps>[];
|
||||
board: InferInsertModel<typeof boards>;
|
||||
@@ -131,28 +122,7 @@ export const prepareBoardImport = (
|
||||
integrationRefToId.set(bundleIntegration.ref, existingIntegration.id);
|
||||
}
|
||||
|
||||
const board: PreparedBundleImport["board"] = {
|
||||
id: boardId,
|
||||
name: boardName,
|
||||
creatorId,
|
||||
isPublic: sourceBoard.settings.isPublic ?? false,
|
||||
pageTitle: sourceBoard.settings.pageTitle ?? null,
|
||||
metaTitle: sourceBoard.settings.metaTitle ?? null,
|
||||
logoImageUrl: sourceBoard.settings.logoImageUrl ?? null,
|
||||
faviconImageUrl: sourceBoard.settings.faviconImageUrl ?? null,
|
||||
backgroundImageUrl: sourceBoard.settings.backgroundImageUrl ?? null,
|
||||
backgroundImageAttachment:
|
||||
sourceBoard.settings.backgroundImageAttachment ?? backgroundImageAttachments.defaultValue,
|
||||
backgroundImageRepeat: sourceBoard.settings.backgroundImageRepeat ?? backgroundImageRepeats.defaultValue,
|
||||
backgroundImageSize: sourceBoard.settings.backgroundImageSize ?? backgroundImageSizes.defaultValue,
|
||||
primaryColor: sourceBoard.settings.primaryColor,
|
||||
secondaryColor: sourceBoard.settings.secondaryColor,
|
||||
opacity: sourceBoard.settings.opacity,
|
||||
customCss: sourceBoard.settings.customCss ?? null,
|
||||
iconColor: sourceBoard.settings.iconColor ?? null,
|
||||
itemRadius: sourceBoard.settings.itemRadius,
|
||||
disableStatus: sourceBoard.settings.disableStatus ?? false,
|
||||
};
|
||||
const board: PreparedBundleImport["board"] = buildBoardInsertRow(boardId, boardName, creatorId, sourceBoard.settings);
|
||||
|
||||
const layouts = sourceBoard.layouts.map((layout) => ({
|
||||
id: mustGet(layoutRefToId, layout.ref),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
export type EntityResolverDef<TBundle, TExisting extends { id: string }, TInsert> = {
|
||||
getRef: (bundle: TBundle) => string;
|
||||
match: (bundle: TBundle, existing: TExisting) => boolean;
|
||||
toInsertRow: (bundle: TBundle, newId: string) => TInsert;
|
||||
skipMessage?: (bundle: TBundle) => string;
|
||||
};
|
||||
|
||||
export type ResolveResult<TInsert> = {
|
||||
refToId: Map<string, string>;
|
||||
rows: TInsert[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export const resolveEntities = <TBundle, TExisting extends { id: string }, TInsert>(
|
||||
bundleEntities: TBundle[],
|
||||
existingEntities: TExisting[],
|
||||
def: EntityResolverDef<TBundle, TExisting, TInsert>,
|
||||
): ResolveResult<TInsert> => {
|
||||
const refToId = new Map<string, string>();
|
||||
const rows: TInsert[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const bundleEntity of bundleEntities) {
|
||||
const existing = existingEntities.find((e) => def.match(bundleEntity, e));
|
||||
|
||||
if (existing) {
|
||||
refToId.set(def.getRef(bundleEntity), existing.id);
|
||||
if (def.skipMessage) warnings.push(def.skipMessage(bundleEntity));
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = createId();
|
||||
refToId.set(def.getRef(bundleEntity), newId);
|
||||
rows.push(def.toInsertRow(bundleEntity, newId));
|
||||
}
|
||||
|
||||
return { refToId, rows, warnings };
|
||||
};
|
||||
|
||||
export const groupByKey = <T, K extends string | number>(
|
||||
items: T[],
|
||||
getKey: (item: T) => K,
|
||||
): Map<K, T[]> => {
|
||||
const map = new Map<K, T[]>();
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(item);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
@@ -78,6 +78,7 @@ const bundleItemSchema = z.object({
|
||||
});
|
||||
|
||||
const bundleBoardSchema = z.object({
|
||||
ref: z.string().optional(),
|
||||
name: z.string(),
|
||||
settings: bundleBoardSettingsSchema,
|
||||
layouts: z.array(bundleLayoutSchema),
|
||||
@@ -141,10 +142,31 @@ const configBundleSearchEngineSchema = z.object({
|
||||
integrationRef: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const configBundleUserSchema = z
|
||||
.object({
|
||||
ref: z.string(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
password: z.string().nullable(),
|
||||
provider: z.string(),
|
||||
homeBoardRef: z.string().nullable(),
|
||||
mobileHomeBoardRef: z.string().nullable(),
|
||||
defaultSearchEngineRef: z.string().nullable(),
|
||||
colorScheme: z.string(),
|
||||
firstDayOfWeek: z.number(),
|
||||
openSearchInNewTab: z.boolean(),
|
||||
ddgBangs: z.boolean(),
|
||||
pingIconsEnabled: z.boolean(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
const configBundleGroupSchema = z.object({
|
||||
ref: z.string(),
|
||||
name: z.string(),
|
||||
position: z.number(),
|
||||
ownerRef: z.string().nullable().optional(),
|
||||
homeBoardRef: z.string().nullable().optional(),
|
||||
mobileHomeBoardRef: z.string().nullable().optional(),
|
||||
permissions: z.array(z.string()),
|
||||
boardPermissions: z.array(
|
||||
z.object({ boardRef: z.string(), permission: z.string() }),
|
||||
@@ -152,6 +174,7 @@ const configBundleGroupSchema = z.object({
|
||||
integrationPermissions: z.array(
|
||||
z.object({ integrationRef: z.string(), permission: z.string() }),
|
||||
),
|
||||
memberRefs: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const homarrConfigBundleSchema = z.object({
|
||||
@@ -166,9 +189,11 @@ export const homarrConfigBundleSchema = z.object({
|
||||
serverSettings: z.record(z.string(), z.unknown()),
|
||||
searchEngines: z.array(configBundleSearchEngineSchema),
|
||||
groups: z.array(configBundleGroupSchema),
|
||||
users: z.array(configBundleUserSchema).optional(),
|
||||
});
|
||||
|
||||
export type HomarrConfigBundle = z.infer<typeof homarrConfigBundleSchema>;
|
||||
export type ConfigBundleIntegration = z.infer<typeof configBundleIntegrationSchema>;
|
||||
export type ConfigBundleGroup = z.infer<typeof configBundleGroupSchema>;
|
||||
export type ConfigBundleSearchEngine = z.infer<typeof configBundleSearchEngineSchema>;
|
||||
export type ConfigBundleUser = z.infer<typeof configBundleUserSchema>;
|
||||
@@ -1,17 +1,25 @@
|
||||
import type { BundleCompatibility } from "./config-bundle-compat";
|
||||
|
||||
export type ConfigEntityCounts = {
|
||||
boards: number;
|
||||
apps: number;
|
||||
integrations: number;
|
||||
secrets: number;
|
||||
widgets: number;
|
||||
sections: number;
|
||||
layouts: number;
|
||||
searchEngines: number;
|
||||
groups: number;
|
||||
serverSettings: number;
|
||||
};
|
||||
export const CONFIG_ENTITY_KEYS = [
|
||||
"boards",
|
||||
"apps",
|
||||
"integrations",
|
||||
"secrets",
|
||||
"widgets",
|
||||
"sections",
|
||||
"layouts",
|
||||
"searchEngines",
|
||||
"groups",
|
||||
"users",
|
||||
"serverSettings",
|
||||
] as const;
|
||||
|
||||
export type ConfigEntityKey = (typeof CONFIG_ENTITY_KEYS)[number];
|
||||
|
||||
export type ConfigEntityCounts = Record<ConfigEntityKey, number>;
|
||||
|
||||
export const emptyCounts = (): ConfigEntityCounts =>
|
||||
Object.fromEntries(CONFIG_ENTITY_KEYS.map((key) => [key, 0])) as ConfigEntityCounts;
|
||||
|
||||
export type ConfigExportPreview = ConfigEntityCounts;
|
||||
|
||||
@@ -26,6 +34,7 @@ export type ConfigImportPreview = {
|
||||
toReuse: {
|
||||
apps: number;
|
||||
integrations: number;
|
||||
users: number;
|
||||
};
|
||||
toSkip: {
|
||||
boards: number;
|
||||
|
||||
@@ -1,52 +1,74 @@
|
||||
import superjson from "superjson";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { emptySuperJSON } from "@homarr/definitions";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type { boards } from "@homarr/db/schema";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes, emptySuperJSON } from "@homarr/definitions";
|
||||
|
||||
import type { HomarrBundleBoard } from "./schema";
|
||||
|
||||
export const parseStoredValue = <T>(value: string | null | undefined, fallback: T): T => {
|
||||
if (!value || value === emptySuperJSON) {
|
||||
return fallback;
|
||||
}
|
||||
return superjson.parse(value) as T;
|
||||
return SuperJSON.parse(value) as T;
|
||||
};
|
||||
|
||||
export const stringifyForDb = (value: unknown): string => {
|
||||
return superjson.stringify(value);
|
||||
return SuperJSON.stringify(value);
|
||||
};
|
||||
|
||||
export const replaceAppIdsInValue = (value: unknown, appIdToRef: Map<string, string>): unknown => {
|
||||
export const mustGet = <T>(map: Map<string, T>, key: string): T => {
|
||||
const value = map.get(key);
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing map entry for key "${key}"`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const replaceMapValues = (value: unknown, map: Map<string, string>): unknown => {
|
||||
if (typeof value === "string") {
|
||||
const ref = appIdToRef.get(value);
|
||||
return ref ?? value;
|
||||
return map.get(value) ?? value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => replaceAppIdsInValue(entry, appIdToRef));
|
||||
return value.map((entry) => replaceMapValues(entry, map));
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, replaceAppIdsInValue(entry, appIdToRef)]),
|
||||
Object.entries(value).map(([key, entry]) => [key, replaceMapValues(entry, map)]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const replaceAppRefsInValue = (value: unknown, refToAppId: Map<string, string>): unknown => {
|
||||
if (typeof value === "string") {
|
||||
const appId = refToAppId.get(value);
|
||||
return appId ?? value;
|
||||
}
|
||||
export const replaceAppIdsInValue = replaceMapValues;
|
||||
export const replaceAppRefsInValue = replaceMapValues;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => replaceAppRefsInValue(entry, refToAppId));
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, replaceAppRefsInValue(entry, refToAppId)]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
export const buildBoardInsertRow = (
|
||||
boardId: string,
|
||||
name: string,
|
||||
creatorId: string | null,
|
||||
settings: HomarrBundleBoard["settings"],
|
||||
): InferInsertModel<typeof boards> => ({
|
||||
id: boardId,
|
||||
name,
|
||||
creatorId,
|
||||
isPublic: settings.isPublic ?? false,
|
||||
pageTitle: settings.pageTitle ?? null,
|
||||
metaTitle: settings.metaTitle ?? null,
|
||||
logoImageUrl: settings.logoImageUrl ?? null,
|
||||
faviconImageUrl: settings.faviconImageUrl ?? null,
|
||||
backgroundImageUrl: settings.backgroundImageUrl ?? null,
|
||||
backgroundImageAttachment: settings.backgroundImageAttachment ?? backgroundImageAttachments.defaultValue,
|
||||
backgroundImageRepeat: settings.backgroundImageRepeat ?? backgroundImageRepeats.defaultValue,
|
||||
backgroundImageSize: settings.backgroundImageSize ?? backgroundImageSizes.defaultValue,
|
||||
primaryColor: settings.primaryColor,
|
||||
secondaryColor: settings.secondaryColor,
|
||||
opacity: settings.opacity,
|
||||
customCss: settings.customCss ?? null,
|
||||
iconColor: settings.iconColor ?? null,
|
||||
itemRadius: settings.itemRadius,
|
||||
disableStatus: settings.disableStatus ?? false,
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE `docker_app_source` (
|
||||
`host` varchar(512) NOT NULL,
|
||||
`container_id` varchar(128) NOT NULL,
|
||||
`external_id` varchar(512) NOT NULL,
|
||||
`app_id` varchar(64) NOT NULL,
|
||||
`board_id` varchar(64) NOT NULL,
|
||||
`item_id` varchar(64),
|
||||
`integration_id` varchar(64),
|
||||
`updated_at` timestamp NOT NULL,
|
||||
CONSTRAINT `docker_app_source_host_container_id_pk` PRIMARY KEY(`host`,`container_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE "docker_app_source" (
|
||||
"host" varchar(512) NOT NULL,
|
||||
"container_id" varchar(128) NOT NULL,
|
||||
"external_id" varchar(512) NOT NULL,
|
||||
"app_id" varchar(64) NOT NULL,
|
||||
"board_id" varchar(64) NOT NULL,
|
||||
"item_id" varchar(64),
|
||||
"integration_id" varchar(64),
|
||||
"updated_at" timestamp NOT NULL,
|
||||
CONSTRAINT "docker_app_source_host_container_id_pk" PRIMARY KEY("host","container_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "docker_app_source__external_id_idx" ON "docker_app_source" USING btree ("host","external_id");
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE `docker_app_source` (
|
||||
`host` text NOT NULL,
|
||||
`container_id` text NOT NULL,
|
||||
`external_id` text NOT NULL,
|
||||
`app_id` text NOT NULL,
|
||||
`board_id` text NOT NULL,
|
||||
`item_id` text,
|
||||
`integration_id` text,
|
||||
`updated_at` integer NOT NULL,
|
||||
PRIMARY KEY(`host`, `container_id`),
|
||||
FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import type { IntegrationKind, IntegrationSecretKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "./integration";
|
||||
import type { IntegrationSecretKind } from "./integration";
|
||||
import type { WidgetKind } from "./widget";
|
||||
|
||||
export interface HomepageWidgetMapping {
|
||||
integrationKind: IntegrationKind;
|
||||
@@ -17,3 +17,4 @@ export * from "./emptysuperjson";
|
||||
export * from "./hotkeys";
|
||||
export * from "./docker-integration-match";
|
||||
export * from "./integration-url-template";
|
||||
export * from "./homepage-widget-map";
|
||||
@@ -4,134 +4,466 @@ import { describe, expect, test } from "vitest";
|
||||
import { dockerLabels, homepageLabels } from "../../labels";
|
||||
import { parseContainerLabels } from "../parse-container-labels";
|
||||
|
||||
const createContainer = (labels: Record<string, string>): Pick<ContainerInfo, "Id" | "Labels"> => ({
|
||||
Id: "container-abc123",
|
||||
const createContainer = (labels: Record<string, string>, id = "container-abc123"): Pick<ContainerInfo, "Id" | "Labels"> => ({
|
||||
Id: id,
|
||||
Labels: labels,
|
||||
});
|
||||
|
||||
describe("parseContainerLabels", () => {
|
||||
test("parses full homarr labels into DiscoveredService", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Jellyfin",
|
||||
[dockerLabels.href]: "https://jellyfin.local",
|
||||
[dockerLabels.icon]: "https://cdn.example/icon.png",
|
||||
[dockerLabels.description]: "Media server",
|
||||
[dockerLabels.ping]: "https://jellyfin.local/health",
|
||||
[dockerLabels.id]: "jellyfin-1",
|
||||
[dockerLabels.board]: "homelab",
|
||||
[dockerLabels.integration]: "jellyfin",
|
||||
[dockerLabels.widget]: "mediaServer",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
describe("homarr native labels", () => {
|
||||
test("parses all homarr labels into DiscoveredService", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Jellyfin",
|
||||
[dockerLabels.href]: "https://jellyfin.local",
|
||||
[dockerLabels.icon]: "https://cdn.example/icon.png",
|
||||
[dockerLabels.description]: "Media server",
|
||||
[dockerLabels.ping]: "https://jellyfin.local/health",
|
||||
[dockerLabels.id]: "jellyfin-1",
|
||||
[dockerLabels.board]: "homelab",
|
||||
[dockerLabels.integration]: "jellyfin",
|
||||
[dockerLabels.widget]: "mediaServer",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "socket",
|
||||
group: "Media",
|
||||
name: "Jellyfin",
|
||||
href: "https://jellyfin.local",
|
||||
icon: "https://cdn.example/icon.png",
|
||||
description: "Media server",
|
||||
pingUrl: "https://jellyfin.local/health",
|
||||
externalId: "jellyfin-1",
|
||||
boardName: "homelab",
|
||||
integrationKind: "jellyfin",
|
||||
widgetKind: "mediaServer",
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "socket",
|
||||
group: "Media",
|
||||
name: "Jellyfin",
|
||||
href: "https://jellyfin.local",
|
||||
icon: "https://cdn.example/icon.png",
|
||||
description: "Media server",
|
||||
pingUrl: "https://jellyfin.local/health",
|
||||
externalId: "jellyfin-1",
|
||||
boardName: "homelab",
|
||||
integrationKind: "jellyfin",
|
||||
widgetKind: "mediaServer",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses minimal required homarr labels (name, group, href)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Networking",
|
||||
[dockerLabels.name]: "Pi-hole",
|
||||
[dockerLabels.href]: "http://pihole:80/admin",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "socket",
|
||||
group: "Networking",
|
||||
name: "Pi-hole",
|
||||
href: "http://pihole:80/admin",
|
||||
externalId: "container-abc123",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses container ID as externalId when homarr.id is absent", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer(
|
||||
{
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Plex",
|
||||
[dockerLabels.href]: "http://plex:32400",
|
||||
},
|
||||
"sha256:deadbeef1234",
|
||||
),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.externalId).toBe("sha256:deadbeef1234");
|
||||
});
|
||||
|
||||
test("uses homarr.id as externalId when present", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Plex",
|
||||
[dockerLabels.href]: "http://plex:32400",
|
||||
[dockerLabels.id]: "plex-server-main",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.externalId).toBe("plex-server-main");
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to homepage labels when homarr.name is absent", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Downloads",
|
||||
[homepageLabels.name]: "Sonarr",
|
||||
[homepageLabels.href]: "https://sonarr.local",
|
||||
[homepageLabels.icon]: "https://cdn.example/sonarr.png",
|
||||
[homepageLabels.description]: "TV shows",
|
||||
}),
|
||||
"192.168.1.10:2375",
|
||||
);
|
||||
describe("homepage fallback labels", () => {
|
||||
test("uses homepage labels when homarr.name is absent", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Downloads",
|
||||
[homepageLabels.name]: "Sonarr",
|
||||
[homepageLabels.href]: "https://sonarr.local",
|
||||
[homepageLabels.icon]: "https://cdn.example/sonarr.png",
|
||||
[homepageLabels.description]: "TV shows",
|
||||
}),
|
||||
"192.168.1.10:2375",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "192.168.1.10:2375",
|
||||
group: "Downloads",
|
||||
name: "Sonarr",
|
||||
href: "https://sonarr.local",
|
||||
icon: "https://cdn.example/sonarr.png",
|
||||
description: "TV shows",
|
||||
externalId: "container-abc123",
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "192.168.1.10:2375",
|
||||
group: "Downloads",
|
||||
name: "Sonarr",
|
||||
href: "https://sonarr.local",
|
||||
icon: "https://cdn.example/sonarr.png",
|
||||
description: "TV shows",
|
||||
externalId: "container-abc123",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not use homepage fallback when readHomepageLabels is false", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Downloads",
|
||||
[homepageLabels.name]: "Sonarr",
|
||||
[homepageLabels.href]: "https://sonarr.local",
|
||||
}),
|
||||
"socket",
|
||||
{ readHomepageLabels: false },
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("homepage labels have no ping, board, integration, or widget support", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Media",
|
||||
[homepageLabels.name]: "Emby",
|
||||
[homepageLabels.href]: "http://emby:8096",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.pingUrl).toBeUndefined();
|
||||
expect(result?.boardName).toBeUndefined();
|
||||
expect(result?.integrationKind).toBeUndefined();
|
||||
expect(result?.widgetKind).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("skips containers with homarr.hide", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.hide]: "true",
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Hidden",
|
||||
[dockerLabels.href]: "https://hidden.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("skips containers missing required labels", () => {
|
||||
expect(
|
||||
parseContainerLabels(
|
||||
describe("label priority and mixing", () => {
|
||||
test("prefers homarr labels over homepage when both are present", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.name]: "Incomplete",
|
||||
[dockerLabels.href]: "https://incomplete.local",
|
||||
[dockerLabels.group]: "Homarr Group",
|
||||
[dockerLabels.name]: "Homarr Name",
|
||||
[dockerLabels.href]: "https://homarr.local",
|
||||
[homepageLabels.group]: "Homepage Group",
|
||||
[homepageLabels.name]: "Homepage Name",
|
||||
[homepageLabels.href]: "https://homepage.local",
|
||||
}),
|
||||
"socket",
|
||||
),
|
||||
).toBeNull();
|
||||
);
|
||||
|
||||
expect(
|
||||
parseContainerLabels(
|
||||
expect(result?.group).toBe("Homarr Group");
|
||||
expect(result?.name).toBe("Homarr Name");
|
||||
expect(result?.href).toBe("https://homarr.local");
|
||||
});
|
||||
|
||||
test("disables homepage fallback entirely when homarr.name is present", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.name]: "No group",
|
||||
[homepageLabels.href]: "https://nogroup.local",
|
||||
[dockerLabels.name]: "MyApp",
|
||||
[dockerLabels.href]: "http://myapp:3000",
|
||||
[homepageLabels.group]: "FromHomepage",
|
||||
}),
|
||||
"socket",
|
||||
),
|
||||
).toBeNull();
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("uses homarr icon with homepage name/group/href when homarr.name absent but homarr.icon present", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Media",
|
||||
[homepageLabels.name]: "Jellyfin",
|
||||
[homepageLabels.href]: "http://jellyfin:8096",
|
||||
[dockerLabels.icon]: "https://custom-icon.example/jf.png",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.name).toBe("Jellyfin");
|
||||
expect(result?.icon).toBe("https://custom-icon.example/jf.png");
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers homarr labels over homepage when both are present", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Homarr Group",
|
||||
[dockerLabels.name]: "Homarr Name",
|
||||
[dockerLabels.href]: "https://homarr.local",
|
||||
[homepageLabels.group]: "Homepage Group",
|
||||
[homepageLabels.name]: "Homepage Name",
|
||||
[homepageLabels.href]: "https://homepage.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
describe("homarr.hide", () => {
|
||||
test("skips container when homarr.hide is present with value 'true'", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.hide]: "true",
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Hidden",
|
||||
[dockerLabels.href]: "https://hidden.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.group).toBe("Homarr Group");
|
||||
expect(result?.name).toBe("Homarr Name");
|
||||
expect(result?.href).toBe("https://homarr.local");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("skips container when homarr.hide is present with empty value", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.hide]: "",
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Hidden",
|
||||
[dockerLabels.href]: "https://hidden.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("skips container when homarr.hide is any string (key presence is enough)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.hide]: "anything",
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Hidden",
|
||||
[dockerLabels.href]: "https://hidden.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not use homepage fallback when readHomepageLabels is false", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Downloads",
|
||||
[homepageLabels.name]: "Sonarr",
|
||||
[homepageLabels.href]: "https://sonarr.local",
|
||||
}),
|
||||
"socket",
|
||||
{ readHomepageLabels: false },
|
||||
);
|
||||
describe("missing required labels", () => {
|
||||
test("returns null when group is missing", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.name]: "NoGroup",
|
||||
[dockerLabels.href]: "https://nogroup.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when name is missing", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.href]: "https://noname.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when href is missing", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "NoHref",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when homepage labels are incomplete (missing group)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.name]: "Incomplete",
|
||||
[homepageLabels.href]: "http://incomplete.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespace handling", () => {
|
||||
test("trims whitespace from all label values", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: " Media ",
|
||||
[dockerLabels.name]: " Jellyfin ",
|
||||
[dockerLabels.href]: " https://jellyfin.local ",
|
||||
[dockerLabels.icon]: " https://icon.png ",
|
||||
[dockerLabels.description]: " A media server ",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.group).toBe("Media");
|
||||
expect(result?.name).toBe("Jellyfin");
|
||||
expect(result?.href).toBe("https://jellyfin.local");
|
||||
expect(result?.icon).toBe("https://icon.png");
|
||||
expect(result?.description).toBe("A media server");
|
||||
});
|
||||
|
||||
test("treats whitespace-only values as empty (missing)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: " ",
|
||||
[dockerLabels.href]: "https://example.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration and widget kind validation", () => {
|
||||
test("parses valid integration kind", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Radarr",
|
||||
[dockerLabels.href]: "http://radarr:7878",
|
||||
[dockerLabels.integration]: "radarr",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.integrationKind).toBe("radarr");
|
||||
});
|
||||
|
||||
test("ignores invalid integration kind", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Custom App",
|
||||
[dockerLabels.href]: "http://custom:3000",
|
||||
[dockerLabels.integration]: "nonExistentIntegration",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.integrationKind).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses valid widget kind", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Jellyfin",
|
||||
[dockerLabels.href]: "http://jellyfin:8096",
|
||||
[dockerLabels.widget]: "mediaServer",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.widgetKind).toBe("mediaServer");
|
||||
});
|
||||
|
||||
test("ignores invalid widget kind", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Custom",
|
||||
[dockerLabels.href]: "http://custom:3000",
|
||||
[dockerLabels.widget]: "totallyFakeWidget",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.widgetKind).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles empty integration kind gracefully", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "App",
|
||||
[dockerLabels.href]: "http://app:3000",
|
||||
[dockerLabels.integration]: "",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.integrationKind).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
test("homepage-only service (common migration use case)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Monitoring",
|
||||
[homepageLabels.name]: "Grafana",
|
||||
[homepageLabels.href]: "http://grafana:3000",
|
||||
[homepageLabels.icon]: "grafana",
|
||||
[homepageLabels.description]: "Metrics and dashboards",
|
||||
}),
|
||||
"tcp://192.168.1.50:2375",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "tcp://192.168.1.50:2375",
|
||||
group: "Monitoring",
|
||||
name: "Grafana",
|
||||
href: "http://grafana:3000",
|
||||
icon: "grafana",
|
||||
description: "Metrics and dashboards",
|
||||
externalId: "container-abc123",
|
||||
});
|
||||
});
|
||||
|
||||
test("service targeting a specific board", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Infrastructure",
|
||||
[dockerLabels.name]: "Traefik",
|
||||
[dockerLabels.href]: "http://traefik:8080",
|
||||
[dockerLabels.board]: "networking-board",
|
||||
[dockerLabels.icon]: "https://cdn.example/traefik.svg",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.boardName).toBe("networking-board");
|
||||
});
|
||||
|
||||
test("service with all optional fields empty uses defaults", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer(
|
||||
{
|
||||
[dockerLabels.group]: "Tools",
|
||||
[dockerLabels.name]: "Portainer",
|
||||
[dockerLabels.href]: "http://portainer:9000",
|
||||
},
|
||||
"abc123def456",
|
||||
),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.icon).toBeUndefined();
|
||||
expect(result?.description).toBeUndefined();
|
||||
expect(result?.pingUrl).toBeUndefined();
|
||||
expect(result?.boardName).toBeUndefined();
|
||||
expect(result?.integrationKind).toBeUndefined();
|
||||
expect(result?.widgetKind).toBeUndefined();
|
||||
expect(result?.externalId).toBe("abc123def456");
|
||||
});
|
||||
|
||||
test("container with no labels returns null", () => {
|
||||
const result = parseContainerLabels(createContainer({}), "socket");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Button, FileInput, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { IconFileUpload } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { homarrBundleSchema } from "@homarr/board-portability/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { boardNameSchema } from "@homarr/validation/board";
|
||||
|
||||
import { useBoardNameStatus } from "./add-board-modal";
|
||||
|
||||
export const ImportBundleModal = createModal(({ actions }) => {
|
||||
const t = useScopedI18n("board.action.importBundle" as never) as unknown as (key: string) => string;
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [fileValid, setFileValid] = useState(true);
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.file(),
|
||||
name: boardNameSchema,
|
||||
}),
|
||||
{
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
name: "",
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
void (async () => {
|
||||
if (values.file === previous.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!values.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await values.file.text();
|
||||
const result = homarrBundleSchema.safeParse(JSON.parse(content));
|
||||
|
||||
if (!result.success) {
|
||||
setFileValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFileValid(true);
|
||||
const boardName = result.data.boards[0]?.name.replaceAll(" ", "-") ?? "";
|
||||
form.setFieldValue("name", boardName);
|
||||
})();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.importBundle.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
const boardNameStatus = useBoardNameStatus(form.values.name);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
if (!fileValid || !boardNameStatus.canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const content = await values.file.text();
|
||||
await mutateAsync(
|
||||
{ content, name: values.name },
|
||||
{
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
})();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
withAsterisk
|
||||
accept="application/json"
|
||||
{...form.getInputProps("file")}
|
||||
error={
|
||||
(form.getInputProps("file").error as string | undefined) ??
|
||||
(!fileValid && form.isDirty("file") ? t("form.file.invalidError") : undefined)
|
||||
}
|
||||
type="button"
|
||||
label={t("form.file.label")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={t("form.name.label")}
|
||||
description={
|
||||
boardNameStatus.description ? (
|
||||
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||
<span>{boardNameStatus.description.label}</span>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{tCommon("action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => (t as (key: string) => string)("board.action.importBundle.label"),
|
||||
size: "lg",
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Code, FileInput, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
|
||||
import { IconFileUpload } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
extractHomepageEnvVariables,
|
||||
parseServicesYaml,
|
||||
replaceHomepageEnvVariables,
|
||||
} from "@homarr/board-portability/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { boardNameSchema } from "@homarr/validation/board";
|
||||
|
||||
import { useBoardNameStatus } from "./add-board-modal";
|
||||
|
||||
export const ImportHomepageModal = createModal(({ actions }) => {
|
||||
const t = useScopedI18n("board.action.importHomepage" as never) as unknown as (key: string) => string;
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [fileValid, setFileValid] = useState(true);
|
||||
const [rawContent, setRawContent] = useState("");
|
||||
const [envVars, setEnvVars] = useState<string[]>([]);
|
||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.file(),
|
||||
boardName: boardNameSchema,
|
||||
createIntegrations: z.boolean(),
|
||||
}),
|
||||
{
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
boardName: "",
|
||||
createIntegrations: true,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
void (async () => {
|
||||
if (values.file === previous.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!values.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await values.file.text();
|
||||
setRawContent(content);
|
||||
|
||||
const vars = extractHomepageEnvVariables(content);
|
||||
setEnvVars(vars);
|
||||
setEnvValues(Object.fromEntries(vars.map((v) => [v, ""])));
|
||||
|
||||
const result = parseServicesYaml(content);
|
||||
|
||||
if (!result.success) {
|
||||
setFileValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFileValid(true);
|
||||
const suggestedName =
|
||||
result.services[0]?.group.replaceAll(/[^A-Za-z0-9-_]/g, "-").replaceAll(/-+/g, "-") ?? "homepage";
|
||||
if (form.values.boardName.trim().length === 0) {
|
||||
form.setFieldValue("boardName", suggestedName.slice(0, 255));
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.importHomepageServices.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
const boardNameStatus = useBoardNameStatus(form.values.boardName);
|
||||
|
||||
const allEnvVarsFilled = envVars.length === 0 || envVars.every((v) => (envValues[v] ?? "").trim().length > 0);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
if (!fileValid || !boardNameStatus.canSubmit || !allEnvVarsFilled) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const resolvedContent =
|
||||
envVars.length > 0 ? replaceHomepageEnvVariables(rawContent, envValues) : rawContent;
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
content: resolvedContent,
|
||||
boardName: values.boardName,
|
||||
createIntegrations: values.createIntegrations,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("notification.error.title"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
})();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
withAsterisk
|
||||
accept=".yaml,.yml"
|
||||
{...form.getInputProps("file")}
|
||||
error={
|
||||
(form.getInputProps("file").error as string | undefined) ??
|
||||
(!fileValid && form.isDirty("file") ? t("field.file.invalidError") : undefined)
|
||||
}
|
||||
type="button"
|
||||
label={t("field.file.label")}
|
||||
/>
|
||||
|
||||
{envVars.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("field.envVariables.label")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("field.envVariables.description")}
|
||||
</Text>
|
||||
{envVars.map((varName) => (
|
||||
<TextInput
|
||||
key={varName}
|
||||
label={<Code>{`{{${varName}}}`}</Code>}
|
||||
placeholder={varName.replace("HOMEPAGE_VAR_", "").toLowerCase().replaceAll("_", ".")}
|
||||
value={envValues[varName] ?? ""}
|
||||
onChange={(event) => {
|
||||
setEnvValues((prev) => ({ ...prev, [varName]: event.target.value }));
|
||||
}}
|
||||
withAsterisk
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={t("field.boardName.label")}
|
||||
description={
|
||||
boardNameStatus.description ? (
|
||||
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||
<span>{boardNameStatus.description.label}</span>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
{...form.getInputProps("boardName")}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t("field.createIntegrations.label")}
|
||||
description={t("field.createIntegrations.description")}
|
||||
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} disabled={!allEnvVarsFilled}>
|
||||
{tCommon("action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => (t as (key: string) => string)("board.action.importHomepage.modal.title"),
|
||||
size: "lg",
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
export { AddBoardModal } from "./add-board-modal";
|
||||
export { ImportBoardModal } from "./import-board-modal";
|
||||
export { ImportBundleModal } from "./import-bundle-modal";
|
||||
export { ImportHomepageModal } from "./import-homepage-modal";
|
||||
export { DuplicateBoardModal } from "./duplicate-board-modal";
|
||||
export { ExportBoardModal } from "./export-board-modal";
|
||||
@@ -19,7 +19,8 @@
|
||||
"description": "Upload a Homarr full configuration export (.json) to restore boards, apps, integrations, and settings"
|
||||
},
|
||||
"configImport": {
|
||||
"action": "Confirm import and continue"
|
||||
"action": "Confirm import and continue",
|
||||
"selectFile": "Select configuration file"
|
||||
},
|
||||
"dropzone": {
|
||||
"title": "Drag the zip file here or click to browse",
|
||||
@@ -3832,6 +3833,7 @@
|
||||
"layouts": "{count} layouts",
|
||||
"searchEngines": "{count} search engines",
|
||||
"groups": "{count} groups",
|
||||
"users": "{count} users",
|
||||
"serverSettings": "{count} server settings"
|
||||
},
|
||||
"preview": {
|
||||
@@ -3845,6 +3847,7 @@
|
||||
"importWillUpdate": "Will update",
|
||||
"reuseApps": "{count} apps",
|
||||
"reuseIntegrations": "{count} integrations",
|
||||
"reuseUsers": "{count} users",
|
||||
"skipBoards": "{count} boards",
|
||||
"skipGroups": "{count} groups",
|
||||
"skipSearchEngines": "{count} search engines",
|
||||
@@ -3886,6 +3889,13 @@
|
||||
"title": "Import failed",
|
||||
"message": "Failed to import configuration"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"boardsImported": "{count} boards imported",
|
||||
"appsCreated": "{count} apps created",
|
||||
"integrationsCreated": "{count} integrations created",
|
||||
"groupsCreated": "{count} groups created",
|
||||
"usersCreated": "{count} users created"
|
||||
}
|
||||
},
|
||||
"importHomepage": {
|
||||
|
||||
Reference in new issue
Block a user