From f6a598b7748ade5c3a1b9f62d953cc6ff36331b7 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 6 Jun 2026 16:16:23 +0200 Subject: [PATCH] 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 --- .dockerignore | 8 + .gitignore | 4 + .../_steps/import/config-import-dropzone.tsx | 68 --- .../init/_steps/import/init-config-import.tsx | 90 +-- .../init/_steps/import/init-import.tsx | 4 + .../_components/docker-settings-form.tsx | 1 + .../docker/logs/[containerId]/terminal.tsx | 27 +- .../import-export/import-config-card.tsx | 8 +- .../config-import-preview-panel.tsx | 104 ++-- .../config-import/config-summary-list.tsx | 27 +- docs/config-portability.md | 66 --- examples/docker-compose.yml | 149 +++-- mise.toml | 2 - packages/api/src/router/config.ts | 32 +- .../api/src/router/import/import-router.ts | 50 +- .../router/test/docker/docker-router.spec.ts | 15 +- packages/api/src/router/user.ts | 50 +- packages/board-portability/index.ts | 8 +- .../config-bundle-round-trip.spec.ts | 237 ++++++++ .../src/config-bundle-compat.ts | 19 + .../board-portability/src/entity-fields.ts | 59 ++ .../src/export/export-full-config.ts | 103 ++-- .../src/export/preview-export-full-config.ts | 6 +- .../src/export/serialize-board.ts | 2 +- .../board-portability/src/homepage/index.ts | 2 +- .../src/homepage/parse-services-yaml.spec.ts | 2 +- .../src/homepage/parse-services-yaml.ts | 2 +- .../src/homepage/prepare-homepage-import.ts | 275 +++++---- .../board-portability/src/homepage/types.ts | 2 +- .../src/homepage/widget-type-map.spec.ts | 4 +- .../src/import/import-full-config.ts | 550 +++++++++++------- .../src/import/load-existing-entities.ts | 39 ++ .../src/import/plan-full-config-import.ts | 125 ++-- .../src/import/prepare-board.ts | 34 +- .../board-portability/src/resolve-entities.ts | 54 ++ packages/board-portability/src/schema.ts | 25 + packages/board-portability/src/types.ts | 33 +- packages/board-portability/src/utils.ts | 74 ++- .../mysql/0039_add_docker_app_source.sql | 17 - .../postgresql/0005_add_docker_app_source.sql | 17 - .../sqlite/0037_add_docker_app_source.sql | 17 - .../src/homepage-widget-map.ts} | 4 +- packages/definitions/src/index.ts | 1 + .../__tests__/parse-container-labels.spec.ts | 540 +++++++++++++---- .../src/boards/import-bundle-modal.tsx | 139 ----- .../src/boards/import-homepage-modal.tsx | 195 ------- .../modals-collection/src/boards/index.ts | 2 - packages/translation/src/lang/en.json | 12 +- 48 files changed, 1869 insertions(+), 1435 deletions(-) delete mode 100644 apps/nextjs/src/app/[locale]/init/_steps/import/config-import-dropzone.tsx delete mode 100644 docs/config-portability.md delete mode 100644 mise.toml create mode 100644 packages/board-portability/src/__tests__/config-bundle-round-trip.spec.ts create mode 100644 packages/board-portability/src/entity-fields.ts create mode 100644 packages/board-portability/src/import/load-existing-entities.ts create mode 100644 packages/board-portability/src/resolve-entities.ts delete mode 100644 packages/db/migrations/mysql/0039_add_docker_app_source.sql delete mode 100644 packages/db/migrations/postgresql/0005_add_docker_app_source.sql delete mode 100644 packages/db/migrations/sqlite/0037_add_docker_app_source.sql rename packages/{board-portability/src/homepage/widget-type-map.ts => definitions/src/homepage-widget-map.ts} (94%) delete mode 100644 packages/modals-collection/src/boards/import-bundle-modal.tsx delete mode 100644 packages/modals-collection/src/boards/import-homepage-modal.tsx diff --git a/.dockerignore b/.dockerignore index d75e30111..60d8c80c8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index 0e991cc6c..c311ddd68 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,7 @@ en.d.json.ts # personal agent config AGENTS.md .opencode + +# local docker dev +.docker-data/ +.docker-cache/ diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/config-import-dropzone.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/config-import-dropzone.tsx deleted file mode 100644 index 7a5e5542e..000000000 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/config-import-dropzone.tsx +++ /dev/null @@ -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 ( - { - 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 - ")}`, - ), - ); - }} - > - - - - - - - - - - - -
- - {tDropzone("title")} - - - {tDropzone("description")} - -
-
-
- ); -}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/init-config-import.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/init-config-import.tsx index 3cdc813b7..dad23dcdc 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/init-config-import.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/init-config-import.tsx @@ -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; const [file, setFile] = useState(null); const [fileContent, setFileContent] = useState(""); + const inputRef = useRef(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 ( - - { - void selectedFile.text().then((content) => { - setFileContent(content); - setFile(selectedFile); - }); - }} - /> - - ); - } + const handleFileChange = (event: React.ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) return; + + void selectedFile.text().then((content) => { + setFileContent(content); + setFile(selectedFile as FileWithPath); + }); + }; return ( - - { - setFile(null); - setFileContent(""); - }} + + - - + + {file && ( + <> + { + setFile(null); + setFileContent(""); + }} + /> + + + + )} + + {!file && ( + + )} ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx index 8d92f484a..afb5625e7 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx @@ -21,6 +21,10 @@ export const InitImport = () => { return ; } + return ; +}; + +const LegacyInitImport = () => { const [file, setFile] = useState(null); const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation(); const [analyseResult, setAnalyseResult] = useState( diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/docker-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/docker-settings-form.tsx index 732a558e1..ca8162a05 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/docker-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/docker-settings-form.tsx @@ -18,6 +18,7 @@ export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSet <> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx index a8d0e1fa8..5aab4ff28 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx @@ -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]); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/import-export/import-config-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/import-export/import-config-card.tsx index 62ec83b34..a1b9e18e5 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/import-export/import-config-card.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/import-export/import-config-card.tsx @@ -107,10 +107,10 @@ export const ImportConfigCard = () => { {importResult && ( }> - {importResult.boards} boards imported - {importResult.apps} apps created - {importResult.integrations} integrations created - {importResult.groups} groups created + {t("management.page.importExport.importConfig.result.boardsImported", { count: importResult.boards })} + {t("management.page.importExport.importConfig.result.appsCreated", { count: importResult.apps })} + {t("management.page.importExport.importConfig.result.integrationsCreated", { count: importResult.integrations })} + {t("management.page.importExport.importConfig.result.groupsCreated", { count: importResult.groups })} )} diff --git a/apps/nextjs/src/components/config-import/config-import-preview-panel.tsx b/apps/nextjs/src/components/config-import/config-import-preview-panel.tsx index 28615aeb8..87df456d6 100644 --- a/apps/nextjs/src/components/config-import/config-import-preview-panel.tsx +++ b/apps/nextjs/src/components/config-import/config-import-preview-panel.tsx @@ -13,6 +13,31 @@ const compatibilityAlertColor: Record 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 - {(preview.toReuse.apps > 0 || preview.toReuse.integrations > 0) && ( - - - {t("management.page.importExport.preview.importWillReuse")} - - - {preview.toReuse.apps > 0 && ( - - {t("management.page.importExport.preview.reuseApps", { count: preview.toReuse.apps })} - - )} - {preview.toReuse.integrations > 0 && ( - - {t("management.page.importExport.preview.reuseIntegrations", { - count: preview.toReuse.integrations, - })} - - )} - - - )} - - {(preview.toSkip.boards > 0 || preview.toSkip.groups > 0 || preview.toSkip.searchEngines > 0) && ( - - - {t("management.page.importExport.preview.importWillSkip")} - - - {preview.toSkip.boards > 0 && ( - - {t("management.page.importExport.preview.skipBoards", { count: preview.toSkip.boards })} - - )} - {preview.toSkip.groups > 0 && ( - - {t("management.page.importExport.preview.skipGroups", { count: preview.toSkip.groups })} - - )} - {preview.toSkip.searchEngines > 0 && ( - - {t("management.page.importExport.preview.skipSearchEngines", { - count: preview.toSkip.searchEngines, - })} - - )} - - - )} - - {preview.toUpdate.serverSettings > 0 && ( - - - {t("management.page.importExport.preview.importWillUpdate")} - - - - {t("management.page.importExport.preview.updateServerSettings", { - count: preview.toUpdate.serverSettings, - })} - - - - )} + {buildCountSections(preview) + .filter((section) => section.entries.length > 0) + .map((section) => ( + + + {t(section.labelKey)} + + + {section.entries.map((entry) => ( + + {t(entry.key, { count: entry.count })} + + ))} + + + ))} {preview.warnings.length > 0 && ( }> diff --git a/apps/nextjs/src/components/config-import/config-summary-list.tsx b/apps/nextjs/src/components/config-import/config-summary-list.tsx index 5b3b002f7..f3f76637a 100644 --- a/apps/nextjs/src/components/config-import/config-summary-list.tsx +++ b/apps/nextjs/src/components/config-import/config-summary-list.tsx @@ -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 = { - 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 ( - {t("management.page.importExport.summary.empty")} + {t(`${TRANSLATION_PREFIX}.empty`)} ); } return ( - {entries.map((key) => ( + {entries.map((key: ConfigEntityKey) => ( - {t(countLabelKeys[key], { count: counts[key] })} + {t(`${TRANSLATION_PREFIX}.${key}`, { count: counts[key] })} ))} diff --git a/docs/config-portability.md b/docs/config-portability.md deleted file mode 100644 index 9b1822142..000000000 --- a/docs/config-portability.md +++ /dev/null @@ -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. diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index fe3dc00bd..033565c85 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -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 diff --git a/mise.toml b/mise.toml deleted file mode 100644 index 81a94b19f..000000000 --- a/mise.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tools] -node = "24.16.0" diff --git a/packages/api/src/router/config.ts b/packages/api/src/router/config.ts index a6412f1f6..d0a7c1cee 100644 --- a/packages/api/src/router/config.ts +++ b/packages/api/src/router/config.ts @@ -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); }), }); diff --git a/packages/api/src/router/import/import-router.ts b/packages/api/src/router/import/import-router.ts index 1dfab7b2f..d012fe572 100644 --- a/packages/api/src/router/import/import-router.ts +++ b/packages/api/src/router/import/import-router.ts @@ -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); }), }); diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index 9dbc130fd..880de927a 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -50,14 +50,13 @@ vi.mock("@homarr/docker/env", () => ({ ENABLE_DOCKER: true, }, })); -vi.mock("@homarr/docker", async (importOriginal) => { - const original = await importOriginal(); - 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 }, diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 466b095fd..02fc96375 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -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 diff --git a/packages/board-portability/index.ts b/packages/board-portability/index.ts index d56dededc..7e945020a 100644 --- a/packages/board-portability/index.ts +++ b/packages/board-portability/index.ts @@ -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"; diff --git a/packages/board-portability/src/__tests__/config-bundle-round-trip.spec.ts b/packages/board-portability/src/__tests__/config-bundle-round-trip.spec.ts new file mode 100644 index 000000000..6ca9e55aa --- /dev/null +++ b/packages/board-portability/src/__tests__/config-bundle-round-trip.spec.ts @@ -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 => ({ + 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).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"); + }); +}); diff --git a/packages/board-portability/src/config-bundle-compat.ts b/packages/board-portability/src/config-bundle-compat.ts index ce75c6264..8cf44b82d 100644 --- a/packages/board-portability/src/config-bundle-compat.ts +++ b/packages/board-portability/src/config-bundle-compat.ts @@ -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; +}; diff --git a/packages/board-portability/src/entity-fields.ts b/packages/board-portability/src/entity-fields.ts new file mode 100644 index 000000000..d2d528bde --- /dev/null +++ b/packages/board-portability/src/entity-fields.ts @@ -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; diff --git a/packages/board-portability/src/export/export-full-config.ts b/packages/board-portability/src/export/export-full-config.ts index 2ecd7f7a9..7dfac03af 100644 --- a/packages/board-portability/src/export/export-full-config.ts +++ b/packages/board-portability/src/export/export-full-config.ts @@ -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>>) => ({ - 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): ConfigBundleUser => { + const bundle: Record = { 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(); - 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(); - for (const gp of allGroupPermissions) { - const list = permissionsByGroup.get(gp.groupId) ?? []; - list.push(gp.permission); - permissionsByGroup.set(gp.groupId, list); - } - - const boardPermsByGroup = new Map(); - 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(); - 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)); + 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); diff --git a/packages/board-portability/src/export/preview-export-full-config.ts b/packages/board-portability/src/export/preview-export-full-config.ts index 49fec76ee..05d64d244 100644 --- a/packages/board-portability/src/export/preview-export-full-config.ts +++ b/packages/board-portability/src/export/preview-export-full-config.ts @@ -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 ({ +export const buildBoardSettings = (board: LoadedBoardGraph) => ({ pageTitle: board.pageTitle, metaTitle: board.metaTitle, logoImageUrl: board.logoImageUrl, diff --git a/packages/board-portability/src/homepage/index.ts b/packages/board-portability/src/homepage/index.ts index 09cc602a5..41a054975 100644 --- a/packages/board-portability/src/homepage/index.ts +++ b/packages/board-portability/src/homepage/index.ts @@ -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"; diff --git a/packages/board-portability/src/homepage/parse-services-yaml.spec.ts b/packages/board-portability/src/homepage/parse-services-yaml.spec.ts index 3b5e6567e..fc45c91f5 100644 --- a/packages/board-portability/src/homepage/parse-services-yaml.spec.ts +++ b/packages/board-portability/src/homepage/parse-services-yaml.spec.ts @@ -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", () => { diff --git a/packages/board-portability/src/homepage/parse-services-yaml.ts b/packages/board-portability/src/homepage/parse-services-yaml.ts index 66fd0ddbe..26e23b030 100644 --- a/packages/board-portability/src/homepage/parse-services-yaml.ts +++ b/packages/board-portability/src/homepage/parse-services-yaml.ts @@ -33,7 +33,7 @@ const normalizeWidget = (widget: Record): HomepageWidget => { type: String(type), ...(typeof url === "string" ? { url } : {}), ...(typeof key === "string" ? { key } : {}), - ...rest, + fields: rest, }; }; diff --git a/packages/board-portability/src/homepage/prepare-homepage-import.ts b/packages/board-portability/src/homepage/prepare-homepage-import.ts index b1636d5c1..102d3fa4a 100644 --- a/packages/board-portability/src/homepage/prepare-homepage-import.ts +++ b/packages/board-portability/src/homepage/prepare-homepage-import.ts @@ -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[]; @@ -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 = { + present: "tooltip", + absent: "hidden", +}; + +type WidgetProcessingContext = { + service: HomepageService; + appId: string; + sectionId: string; + boardId: string; + layoutId: string; + gridState: Map; + integrationRows: InferInsertModel[]; + integrationSecretRows: InferInsertModel[]; + itemRows: InferInsertModel[]; + itemLayoutRows: InferInsertModel[]; + integrationItemRows: InferInsertModel[]; + warnings: string[]; + unmappedWidgetTypesSet: Set; +}; + +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; 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[] = [ - { - 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[] = []; const itemLayoutRows: InferInsertModel[] = []; const integrationItemRows: InferInsertModel[] = []; - - const gridState = new Map(); + const gridState = new Map(); 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 { diff --git a/packages/board-portability/src/homepage/types.ts b/packages/board-portability/src/homepage/types.ts index 797b2711e..b6d8a5082 100644 --- a/packages/board-portability/src/homepage/types.ts +++ b/packages/board-portability/src/homepage/types.ts @@ -13,7 +13,7 @@ export interface HomepageWidget { type: string; url?: string; key?: string; - [key: string]: unknown; + fields: Record; } export type ParseServicesYamlResult = diff --git a/packages/board-portability/src/homepage/widget-type-map.spec.ts b/packages/board-portability/src/homepage/widget-type-map.spec.ts index 7d1219bfb..acd4c2415 100644 --- a/packages/board-portability/src/homepage/widget-type-map.spec.ts +++ b/packages/board-portability/src/homepage/widget-type-map.spec.ts @@ -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", () => { diff --git a/packages/board-portability/src/import/import-full-config.ts b/packages/board-portability/src/import/import-full-config.ts index cc57d6e2d..d14183d45 100644 --- a/packages/board-portability/src/import/import-full-config.ts +++ b/packages/board-portability/src/import/import-full-config.ts @@ -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 = (map: Map, 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, ref: string | null | undefined): string | null => { + if (!ref) return null; + return map.get(ref) ?? null; +}; + +const resolveApps = ( bundle: HomarrConfigBundle, - creatorId: string | null, -): Promise => { - 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(); - 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(); - 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(); - 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(); + const boardsToCreate = new Set(); + 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, +) => { + 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>, +) => + 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 = { id }; + + for (const field of USER_DIRECT_FIELDS) { + row[field] = (bundleUser as Record)[field] ?? null; + } + + for (const [refField, mapName] of Object.entries(USER_REF_FIELDS)) { + const dbCol = refFieldToDbColumn(refField); + row[dbCol] = resolveRef(refMaps[mapName]!, (bundleUser as Record)[refField] as string); + } + + return row as typeof users.$inferInsert; + }, + }); + +const resolveGroups = ( + bundle: HomarrConfigBundle, + existingGroups: { id: string; name: string }[], + existingMembershipKeys: Set, + refMaps: Record>, +) => { + 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 = { 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)[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, + boardRefToId: Map, + appRefToId: Map, + integrationRefToId: Map, + creatorId: string | null, + insertCollection: Pick, "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 => { + 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> = { + boards: resolvedBoardsResult.boardRefToId, + searchEngines: resolvedSearchEngines.refToId, + integrations: resolvedIntegrations.refToId, + users: new Map(), + }; - 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, }; }; diff --git a/packages/board-portability/src/import/load-existing-entities.ts b/packages/board-portability/src/import/load-existing-entities.ts new file mode 100644 index 000000000..e96baf6ad --- /dev/null +++ b/packages/board-portability/src/import/load-existing-entities.ts @@ -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>; + integrations: Awaited>; + boards: Awaited>; + groups: InferSelectModel[]; + searchEngines: InferSelectModel[]; + users: InferSelectModel[]; + groupMembers: InferSelectModel[]; + serverSettings: InferSelectModel[]; +}; + +export const loadExistingEntitiesAsync = async (db: Database): Promise => { + 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, + }; +}; diff --git a/packages/board-portability/src/import/plan-full-config-import.ts b/packages/board-portability/src/import/plan-full-config-import.ts index c21ea515b..15d430fe3 100644 --- a/packages/board-portability/src/import/plan-full-config-import.ts +++ b/packages/board-portability/src/import/plan-full-config-import.ts @@ -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; + 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 => { - 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).exportedAt === "string" ? ((parsed as Record).exportedAt as string) : "", - homarrVersion: - typeof (parsed as Record).homarrVersion === "string" - ? ((parsed as Record).homarrVersion as string) - : "", - bundleVersion: - typeof (parsed as Record).version === "string" - ? ((parsed as Record).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, diff --git a/packages/board-portability/src/import/prepare-board.ts b/packages/board-portability/src/import/prepare-board.ts index 3f570ba9c..c8a5c7ade 100644 --- a/packages/board-portability/src/import/prepare-board.ts +++ b/packages/board-portability/src/import/prepare-board.ts @@ -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 = (map: Map, 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[]; board: InferInsertModel; @@ -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), diff --git a/packages/board-portability/src/resolve-entities.ts b/packages/board-portability/src/resolve-entities.ts new file mode 100644 index 000000000..09bbb50d0 --- /dev/null +++ b/packages/board-portability/src/resolve-entities.ts @@ -0,0 +1,54 @@ +import { createId } from "@homarr/common"; + +export type EntityResolverDef = { + getRef: (bundle: TBundle) => string; + match: (bundle: TBundle, existing: TExisting) => boolean; + toInsertRow: (bundle: TBundle, newId: string) => TInsert; + skipMessage?: (bundle: TBundle) => string; +}; + +export type ResolveResult = { + refToId: Map; + rows: TInsert[]; + warnings: string[]; +}; + +export const resolveEntities = ( + bundleEntities: TBundle[], + existingEntities: TExisting[], + def: EntityResolverDef, +): ResolveResult => { + const refToId = new Map(); + 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 = ( + items: T[], + getKey: (item: T) => K, +): Map => { + const map = new Map(); + for (const item of items) { + const key = getKey(item); + const list = map.get(key) ?? []; + list.push(item); + map.set(key, list); + } + return map; +}; diff --git a/packages/board-portability/src/schema.ts b/packages/board-portability/src/schema.ts index a08eec6c8..109f9405c 100644 --- a/packages/board-portability/src/schema.ts +++ b/packages/board-portability/src/schema.ts @@ -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; export type ConfigBundleIntegration = z.infer; export type ConfigBundleGroup = z.infer; export type ConfigBundleSearchEngine = z.infer; +export type ConfigBundleUser = z.infer; diff --git a/packages/board-portability/src/types.ts b/packages/board-portability/src/types.ts index 4ed0e2518..f24bc6fac 100644 --- a/packages/board-portability/src/types.ts +++ b/packages/board-portability/src/types.ts @@ -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; + +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; diff --git a/packages/board-portability/src/utils.ts b/packages/board-portability/src/utils.ts index bdcffc96e..f48602daa 100644 --- a/packages/board-portability/src/utils.ts +++ b/packages/board-portability/src/utils.ts @@ -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 = (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): unknown => { +export const mustGet = (map: Map, 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): 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): 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 => ({ + 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, +}); diff --git a/packages/db/migrations/mysql/0039_add_docker_app_source.sql b/packages/db/migrations/mysql/0039_add_docker_app_source.sql deleted file mode 100644 index 63ad7f1d2..000000000 --- a/packages/db/migrations/mysql/0039_add_docker_app_source.sql +++ /dev/null @@ -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`); \ No newline at end of file diff --git a/packages/db/migrations/postgresql/0005_add_docker_app_source.sql b/packages/db/migrations/postgresql/0005_add_docker_app_source.sql deleted file mode 100644 index e2bb201df..000000000 --- a/packages/db/migrations/postgresql/0005_add_docker_app_source.sql +++ /dev/null @@ -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"); \ No newline at end of file diff --git a/packages/db/migrations/sqlite/0037_add_docker_app_source.sql b/packages/db/migrations/sqlite/0037_add_docker_app_source.sql deleted file mode 100644 index 7c3e8cc09..000000000 --- a/packages/db/migrations/sqlite/0037_add_docker_app_source.sql +++ /dev/null @@ -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`); \ No newline at end of file diff --git a/packages/board-portability/src/homepage/widget-type-map.ts b/packages/definitions/src/homepage-widget-map.ts similarity index 94% rename from packages/board-portability/src/homepage/widget-type-map.ts rename to packages/definitions/src/homepage-widget-map.ts index a89ec052e..06c6bcb8d 100644 --- a/packages/board-portability/src/homepage/widget-type-map.ts +++ b/packages/definitions/src/homepage-widget-map.ts @@ -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; diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index eb81e4d18..7e1af60e8 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -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"; diff --git a/packages/docker/src/discovery/__tests__/parse-container-labels.spec.ts b/packages/docker/src/discovery/__tests__/parse-container-labels.spec.ts index 458e9345a..0d0994cbd 100644 --- a/packages/docker/src/discovery/__tests__/parse-container-labels.spec.ts +++ b/packages/docker/src/discovery/__tests__/parse-container-labels.spec.ts @@ -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): Pick => ({ - Id: "container-abc123", +const createContainer = (labels: Record, id = "container-abc123"): Pick => ({ + 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(); + }); }); }); diff --git a/packages/modals-collection/src/boards/import-bundle-modal.tsx b/packages/modals-collection/src/boards/import-bundle-modal.tsx deleted file mode 100644 index 5a3e23070..000000000 --- a/packages/modals-collection/src/boards/import-bundle-modal.tsx +++ /dev/null @@ -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 ( -
{ - 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"), - }); - }, - }, - ); - })(); - })} - > - - } - 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")} - /> - - - {boardNameStatus.description.icon ? : null} - {boardNameStatus.description.label} - - ) : null - } - {...form.getInputProps("name")} - /> - - - - - - -
- ); -}).withOptions({ - defaultTitle: (t) => (t as (key: string) => string)("board.action.importBundle.label"), - size: "lg", -}); diff --git a/packages/modals-collection/src/boards/import-homepage-modal.tsx b/packages/modals-collection/src/boards/import-homepage-modal.tsx deleted file mode 100644 index 8a4ca1638..000000000 --- a/packages/modals-collection/src/boards/import-homepage-modal.tsx +++ /dev/null @@ -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([]); - const [envValues, setEnvValues] = useState>({}); - - 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 ( -
{ - 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"), - }); - }, - }, - ); - })(); - })} - > - - } - 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 && ( - - - {t("field.envVariables.label")} - - - {t("field.envVariables.description")} - - {envVars.map((varName) => ( - {`{{${varName}}}`}} - placeholder={varName.replace("HOMEPAGE_VAR_", "").toLowerCase().replaceAll("_", ".")} - value={envValues[varName] ?? ""} - onChange={(event) => { - setEnvValues((prev) => ({ ...prev, [varName]: event.target.value })); - }} - withAsterisk - /> - ))} - - )} - - - {boardNameStatus.description.icon ? : null} - {boardNameStatus.description.label} - - ) : null - } - {...form.getInputProps("boardName")} - /> - - - - - - - - -
- ); -}).withOptions({ - defaultTitle: (t) => (t as (key: string) => string)("board.action.importHomepage.modal.title"), - size: "lg", -}); diff --git a/packages/modals-collection/src/boards/index.ts b/packages/modals-collection/src/boards/index.ts index ab287b15b..0ef24a178 100644 --- a/packages/modals-collection/src/boards/index.ts +++ b/packages/modals-collection/src/boards/index.ts @@ -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"; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 034b67bd7..19a4282ec 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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": {