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:
Thomas Camlong committed 2026-06-06 16:22:59 +02:00
1 parent 1f7b7f0516
commit f6a598b774
48 files changed
+1869 -1435

No files matched your search

+8
View File
@@ -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
+4
View File
@@ -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>
-66
View File
@@ -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
View File
@@ -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
View File
@@ -1,2 +0,0 @@
[tools]
node = "24.16.0"
+7 -25
View File
@@ -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);
}),
});
+15 -35
View File
@@ -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 },
+36 -14
View File
@@ -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
+6 -2
View File
@@ -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;
};
+25
View File
@@ -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>;
+21 -12
View File
@@ -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;
+48 -26
View File
@@ -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`);
@@ -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;
+1
View File
@@ -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";
+11 -1
View File
@@ -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": {