From fb8721bd4eb56dd93dcdb15181902c65c9081511 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 4 Jun 2026 10:56:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20config=20portability=20=E2=80=94?= =?UTF-8?q?=20full=20export/import,=20homepage=20import,=20docker=20label?= =?UTF-8?q?=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated Import/Export tool page with full configuration export/import (v2.0 bundle with encrypted secrets), Homepage services.yaml import with env variable resolution, and manual Docker label discovery (moved from cron job). Support importing Homarr JSON during onboarding alongside legacy zip import. New @homarr/board-portability package handles schema, serialization, compat checks, and import planning. Shared preview UI reused across admin and onboarding. Fix xterm.js crash by removing deprecated @xterm/addon-canvas (incompatible with xterm v6). Fix Homepage env variable i18n and React 19 currentTarget bug. See docs/config-portability.md for full changelog. --- apps/nextjs/package.json | 2 +- .../src/app/[locale]/init/_steps/back.tsx | 3 + .../_steps/import/config-import-dropzone.tsx | 68 + .../init/_steps/import/init-config-import.tsx | 98 + .../init/_steps/import/init-import.tsx | 8 + .../_steps/import/use-init-import-mode.ts | 30 + .../[locale]/init/_steps/start/init-start.tsx | 10 +- .../init/_steps/start/next-button.tsx | 15 +- .../_components/board-card-menu-dropdown.tsx | 22 +- .../_components/create-board-button.tsx | 2 +- .../nextjs/src/app/[locale]/manage/layout.tsx | 7 + .../_components/docker-settings-form.tsx | 57 + .../src/app/[locale]/manage/settings/page.tsx | 9 + .../import-export/docker-discovery-panel.tsx | 124 + .../import-export/export-config-card.tsx | 138 + .../import-export/import-config-card.tsx | 126 + .../import-export/import-homepage-card.tsx | 181 ++ .../manage/tools/import-export/page.tsx | 34 + .../[locale]/manage/tools/logs/terminal.tsx | 34 +- .../config-import-preview-panel.tsx | 160 ++ .../config-import/config-summary-list.tsx | 48 + docs/config-portability.md | 66 + examples/docker-compose.yml | 100 + mise.toml | 2 + packages/api/package.json | 1 + packages/api/src/root.ts | 1 + packages/api/src/router/board.ts | 117 + packages/api/src/router/config.ts | 60 + .../api/src/router/docker/docker-router.ts | 29 +- .../api/src/router/import/import-router.ts | 43 + .../router/test/docker/docker-router.spec.ts | 16 + packages/board-portability/client.ts | 9 + packages/board-portability/index.ts | 21 + packages/board-portability/package.json | 40 + .../src/__tests__/bundle-round-trip.spec.ts | 178 ++ .../src/config-bundle-compat.ts | 99 + .../src/export/export-full-config.ts | 214 ++ .../board-portability/src/export/index.ts | 23 + .../src/export/load-board-graph.ts | 35 + .../src/export/preview-export-full-config.ts | 42 + .../src/export/serialize-board.ts | 168 ++ .../board-portability/src/export/to-json.ts | 10 + .../src/homepage/env-variables.ts | 15 + .../board-portability/src/homepage/import.ts | 76 + .../board-portability/src/homepage/index.ts | 5 + .../src/homepage/parse-services-yaml.spec.ts | 111 + .../src/homepage/parse-services-yaml.ts | 111 + .../src/homepage/prepare-homepage-import.ts | 299 ++ .../board-portability/src/homepage/types.ts | 21 + .../src/homepage/widget-type-map.spec.ts | 40 + .../src/homepage/widget-type-map.ts | 55 + .../src/import/import-full-config.ts | 392 +++ .../board-portability/src/import/index.ts | 4 + .../board-portability/src/import/insert.ts | 79 + .../src/import/match-apps.ts | 14 + .../src/import/parse-bundle.ts | 7 + .../src/import/plan-full-config-import.ts | 196 ++ .../src/import/prepare-board.ts | 233 ++ packages/board-portability/src/schema.ts | 174 ++ packages/board-portability/src/types.ts | 39 + packages/board-portability/src/utils.ts | 52 + packages/board-portability/tsconfig.json | 8 + packages/cli/package.json | 1 + packages/cli/src/commands/boards-export.ts | 40 + packages/cli/src/commands/boards-list.ts | 21 + packages/cli/src/commands/boards.ts | 10 + packages/cli/src/index.ts | 3 +- packages/cron-jobs/package.json | 1 + .../mysql/0039_add_docker_app_source.sql | 17 + .../migrations/mysql/meta/0039_snapshot.json | 2500 +++++++++++++++++ .../db/migrations/mysql/meta/_journal.json | 9 +- .../postgresql/0005_add_docker_app_source.sql | 17 + .../postgresql/meta/0005_snapshot.json | 2360 ++++++++++++++++ .../migrations/postgresql/meta/_journal.json | 9 +- .../sqlite/0037_add_docker_app_source.sql | 17 + .../migrations/sqlite/meta/0037_snapshot.json | 2379 ++++++++++++++++ .../db/migrations/sqlite/meta/_journal.json | 9 +- packages/db/schema/index.ts | 1 + packages/db/schema/mysql.ts | 46 + packages/db/schema/postgresql.ts | 46 + packages/db/schema/sqlite.ts | 46 + packages/docker/package.json | 8 +- .../__tests__/parse-container-labels.spec.ts | 137 + packages/docker/src/discovery/index.ts | 8 + .../discovery/list-discovered-containers.ts | 45 + .../src/discovery/parse-container-labels.ts | 110 + .../src/discovery/sync-discovered-services.ts | 451 +++ packages/docker/src/discovery/types.ts | 16 + packages/docker/src/index.ts | 1 + packages/docker/src/labels.ts | 19 +- packages/modals-collection/package.json | 1 + .../src/boards/export-board-modal.tsx | 64 + .../src/boards/import-bundle-modal.tsx | 139 + .../src/boards/import-homepage-modal.tsx | 195 ++ .../modals-collection/src/boards/index.ts | 3 + packages/server-settings/src/index.ts | 9 + packages/translation/src/lang/en.json | 235 +- packages/validation/src/board.ts | 9 + pnpm-lock.yaml | 86 +- pnpm-workspace.yaml | 1 - 100 files changed, 13394 insertions(+), 56 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/init/_steps/import/config-import-dropzone.tsx create mode 100644 apps/nextjs/src/app/[locale]/init/_steps/import/init-config-import.tsx create mode 100644 apps/nextjs/src/app/[locale]/init/_steps/import/use-init-import-mode.ts create mode 100644 apps/nextjs/src/app/[locale]/manage/settings/_components/docker-settings-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/import-export/docker-discovery-panel.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/import-export/export-config-card.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/import-export/import-config-card.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/import-export/import-homepage-card.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/import-export/page.tsx create mode 100644 apps/nextjs/src/components/config-import/config-import-preview-panel.tsx create mode 100644 apps/nextjs/src/components/config-import/config-summary-list.tsx create mode 100644 docs/config-portability.md create mode 100644 examples/docker-compose.yml create mode 100644 mise.toml create mode 100644 packages/api/src/router/config.ts create mode 100644 packages/board-portability/client.ts create mode 100644 packages/board-portability/index.ts create mode 100644 packages/board-portability/package.json create mode 100644 packages/board-portability/src/__tests__/bundle-round-trip.spec.ts create mode 100644 packages/board-portability/src/config-bundle-compat.ts create mode 100644 packages/board-portability/src/export/export-full-config.ts create mode 100644 packages/board-portability/src/export/index.ts create mode 100644 packages/board-portability/src/export/load-board-graph.ts create mode 100644 packages/board-portability/src/export/preview-export-full-config.ts create mode 100644 packages/board-portability/src/export/serialize-board.ts create mode 100644 packages/board-portability/src/export/to-json.ts create mode 100644 packages/board-portability/src/homepage/env-variables.ts create mode 100644 packages/board-portability/src/homepage/import.ts create mode 100644 packages/board-portability/src/homepage/index.ts create mode 100644 packages/board-portability/src/homepage/parse-services-yaml.spec.ts create mode 100644 packages/board-portability/src/homepage/parse-services-yaml.ts create mode 100644 packages/board-portability/src/homepage/prepare-homepage-import.ts create mode 100644 packages/board-portability/src/homepage/types.ts create mode 100644 packages/board-portability/src/homepage/widget-type-map.spec.ts create mode 100644 packages/board-portability/src/homepage/widget-type-map.ts create mode 100644 packages/board-portability/src/import/import-full-config.ts create mode 100644 packages/board-portability/src/import/index.ts create mode 100644 packages/board-portability/src/import/insert.ts create mode 100644 packages/board-portability/src/import/match-apps.ts create mode 100644 packages/board-portability/src/import/parse-bundle.ts create mode 100644 packages/board-portability/src/import/plan-full-config-import.ts create mode 100644 packages/board-portability/src/import/prepare-board.ts create mode 100644 packages/board-portability/src/schema.ts create mode 100644 packages/board-portability/src/types.ts create mode 100644 packages/board-portability/src/utils.ts create mode 100644 packages/board-portability/tsconfig.json create mode 100644 packages/cli/src/commands/boards-export.ts create mode 100644 packages/cli/src/commands/boards-list.ts create mode 100644 packages/cli/src/commands/boards.ts create mode 100644 packages/db/migrations/mysql/0039_add_docker_app_source.sql create mode 100644 packages/db/migrations/mysql/meta/0039_snapshot.json create mode 100644 packages/db/migrations/postgresql/0005_add_docker_app_source.sql create mode 100644 packages/db/migrations/postgresql/meta/0005_snapshot.json create mode 100644 packages/db/migrations/sqlite/0037_add_docker_app_source.sql create mode 100644 packages/db/migrations/sqlite/meta/0037_snapshot.json create mode 100644 packages/docker/src/discovery/__tests__/parse-container-labels.spec.ts create mode 100644 packages/docker/src/discovery/index.ts create mode 100644 packages/docker/src/discovery/list-discovered-containers.ts create mode 100644 packages/docker/src/discovery/parse-container-labels.ts create mode 100644 packages/docker/src/discovery/sync-discovered-services.ts create mode 100644 packages/docker/src/discovery/types.ts create mode 100644 packages/modals-collection/src/boards/export-board-modal.tsx create mode 100644 packages/modals-collection/src/boards/import-bundle-modal.tsx create mode 100644 packages/modals-collection/src/boards/import-homepage-modal.tsx diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index b0ed1ea99..57d89317f 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -21,6 +21,7 @@ "@homarr/analytics": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0", + "@homarr/board-portability": "workspace:^0.1.0", "@homarr/boards": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0", @@ -63,7 +64,6 @@ "@trpc/next": "catalog:", "@trpc/react-query": "catalog:", "@trpc/server": "catalog:", - "@xterm/addon-canvas": "catalog:", "@xterm/addon-fit": "catalog:", "@xterm/xterm": "catalog:", "babel-plugin-react-compiler": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/init/_steps/back.tsx b/apps/nextjs/src/app/[locale]/init/_steps/back.tsx index 7fa98d283..54417b0fd 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/back.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/back.tsx @@ -6,11 +6,14 @@ import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { useI18n } from "@homarr/translation/client"; +import { clearInitImportMode } from "./import/use-init-import-mode"; + export const BackToStart = () => { const t = useI18n(); const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation(); const handleBackToStartAsync = async () => { + clearInitImportMode(); await mutateAsync(); await revalidatePathActionAsync("/init"); }; 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 new file mode 100644 index 000000000..7a5e5542e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/config-import-dropzone.tsx @@ -0,0 +1,68 @@ +"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 new file mode 100644 index 000000000..3cdc813b7 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/init-config-import.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, Card, Stack } from "@mantine/core"; +import type { FileWithPath } from "@mantine/dropzone"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { showErrorNotification } from "@homarr/notifications"; +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 { + mutate: runPreview, + data: preview, + isPending: previewLoading, + reset: resetPreview, + } = clientApi.import.previewInitialConfigImport.useMutation(); + + const { mutateAsync, isPending } = clientApi.import.importInitialConfigImport.useMutation({ + async onSuccess() { + await revalidatePathActionAsync("/init"); + }, + }); + + useEffect(() => { + if (!fileContent) { + resetPreview(); + return; + } + runPreview({ content: fileContent }); + }, [fileContent, runPreview, resetPreview]); + + const importAllowed = preview?.compatibility.status === "compatible"; + + const handleImport = async () => { + if (!file || !importAllowed) { + return; + } + + await mutateAsync( + { content: fileContent }, + { + onError() { + showErrorNotification({ + title: t("management.page.importExport.importConfig.notification.error.title"), + message: t("management.page.importExport.importConfig.notification.error.message"), + }); + }, + }, + ); + }; + + if (!file) { + return ( + + { + void selectedFile.text().then((content) => { + setFileContent(content); + setFile(selectedFile); + }); + }} + /> + + ); + } + + return ( + + { + setFile(null); + setFileContent(""); + }} + /> + + + + ); +}; 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 cdb3faf50..ae2c26bee 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 @@ -10,8 +10,16 @@ import { InitialOldmarrImport } from "@homarr/old-import/components"; import { FileInfoCard } from "./file-info-card"; import { ImportDropZone } from "./import-dropzone"; +import { InitConfigImport } from "./init-config-import"; +import { useInitImportMode } from "./use-init-import-mode"; export const InitImport = () => { + const importMode = useInitImportMode(); + + if (importMode === "config") { + return ; + } + const [file, setFile] = useState(null); const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation(); const [analyseResult, setAnalyseResult] = useState( diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/use-init-import-mode.ts b/apps/nextjs/src/app/[locale]/init/_steps/import/use-init-import-mode.ts new file mode 100644 index 000000000..dd4462dbb --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/use-init-import-mode.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +export type InitImportMode = "legacy" | "config"; + +const storageKey = "homarr-init-import-mode"; + +const readMode = (): InitImportMode => { + const stored = sessionStorage.getItem(storageKey); + return stored === "config" ? "config" : "legacy"; +}; + +const subscribe = (onStoreChange: () => void) => { + window.addEventListener("storage", onStoreChange); + return () => window.removeEventListener("storage", onStoreChange); +}; + +export const setInitImportMode = (mode: InitImportMode) => { + sessionStorage.setItem(storageKey, mode); + window.dispatchEvent(new Event("storage")); +}; + +export const clearInitImportMode = () => { + sessionStorage.removeItem(storageKey); + window.dispatchEvent(new Event("storage")); +}; + +export const useInitImportMode = () => + useSyncExternalStore(subscribe, readMode, () => "legacy" as InitImportMode); diff --git a/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx b/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx index da858f1f4..894abcec1 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx @@ -1,5 +1,5 @@ import { Card, Stack, Text } from "@mantine/core"; -import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react"; +import { IconFileImport, IconJson, IconPlayerPlay } from "@tabler/icons-react"; import { getMantineColor } from "@homarr/common"; import { getScopedI18n } from "@homarr/translation/server"; @@ -22,10 +22,18 @@ export const InitStart = async () => { } > {tStart("action.importOldmarr")} + } + > + {tStart("action.importConfig")} + ); diff --git a/apps/nextjs/src/app/[locale]/init/_steps/start/next-button.tsx b/apps/nextjs/src/app/[locale]/init/_steps/start/next-button.tsx index 00ecf6f7d..40c62e384 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/start/next-button.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/start/next-button.tsx @@ -7,15 +7,28 @@ import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import type { OnboardingStep } from "@homarr/definitions"; +import { clearInitImportMode, setInitImportMode, type InitImportMode } from "../import/use-init-import-mode"; + interface InitStartButtonProps { icon: ReactNode; preferredStep: OnboardingStep | undefined; + importMode?: InitImportMode; } -export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren) => { +export const InitStartButton = ({ + preferredStep, + icon, + children, + importMode, +}: PropsWithChildren) => { const { mutateAsync } = clientApi.onboard.nextStep.useMutation(); const handleClickAsync = async () => { + if (importMode) { + setInitImportMode(importMode); + } else { + clearInitImportMode(); + } await mutateAsync({ preferredStep }); await revalidatePathActionAsync("/init"); }; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx index e5ae194af..97ba81b4a 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx @@ -2,14 +2,14 @@ import { useCallback } from "react"; import { Menu } from "@mantine/core"; -import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react"; +import { IconCopy, IconDeviceMobile, IconDownload, IconHome, IconSettings, IconTrash } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { useConfirmModal, useModalAction } from "@homarr/modals"; -import { DuplicateBoardModal } from "@homarr/modals-collection"; +import { DuplicateBoardModal, ExportBoardModal } from "@homarr/modals-collection"; import { useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; @@ -28,7 +28,10 @@ interface BoardCardMenuDropdownProps { } export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => { - const t = useScopedI18n("management.page.board.action"); + const t = useScopedI18n("management.page.board.action" as never) as unknown as ( + key: string, + values?: Record, + ) => string; const tCommon = useScopedI18n("common"); const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board); @@ -36,6 +39,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => const { openConfirmModal } = useConfirmModal(); const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal); + const { openModal: openExportModal } = useModalAction(ExportBoardModal); const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({ onSettled: async () => { @@ -87,6 +91,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => }); }, [board.id, board.name, openDuplicateModal]); + const handleExportBoard = useCallback(() => { + openExportModal({ + board: { + id: board.id, + name: board.name, + }, + }); + }, [board.id, board.name, openExportModal]); + return ( }> @@ -95,6 +108,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => }> {t("setMobileHomeBoard.label")} + }> + {t("export.label")} + {session?.user.permissions.includes("board-create") && ( }> {t("duplicate.label")} diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index 5e9642e32..996d22f14 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client"; import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; export const CreateBoardButton = () => { - const t = useI18n(); + const t = useI18n() as unknown as (key: string) => string; const { openModal: openAddModal } = useModalAction(AddBoardModal); const { openModal: openImportModal } = useModalAction(ImportBoardModal); diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 32013221c..74e5bd45c 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -10,6 +10,7 @@ import { IconBrandTablerFilled, IconCertificate, IconClipboardListFilled, + IconDatabaseExport, IconDirectionsFilled, IconGitFork, IconHelpSquareRoundedFilled, @@ -146,6 +147,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) { href: "/manage/tools/tasks", hidden: !session?.user.permissions.includes("admin"), }, + { + label: (t as unknown as (key: string) => string)("items.tools.items.importExport"), + icon: IconDatabaseExport, + href: "/manage/tools/import-export", + hidden: !session?.user.permissions.includes("admin"), + }, ], }, { 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 new file mode 100644 index 000000000..732a558e1 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/docker-settings-form.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { NumberInput, Switch, TextInput } from "@mantine/core"; + +import type { ServerSettings } from "@homarr/server-settings"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { CommonSettingsForm } from "./common-form"; + +export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["docker"] }) => { + const tDocker = useScopedI18n("management.page.settings.section.docker" as never) as unknown as ( + key: string, + ) => string; + + return ( + + {(form) => ( + <> + + + + + + + + )} + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx index 4a7ea9bc0..c1b0254d9 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/page.tsx @@ -5,12 +5,15 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; +import { env } from "@homarr/docker/env"; + import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { AnalyticsSettings } from "./_components/analytics.settings"; import { AppearanceSettingsForm } from "./_components/appearance-settings-form"; import { BoardSettingsForm } from "./_components/board-settings-form"; import { CultureSettingsForm } from "./_components/culture-settings-form"; +import { DockerSettingsForm } from "./_components/docker-settings-form"; import { SearchSettingsForm } from "./_components/search-settings-form"; import { UserSettingsForm } from "./_components/user-settings-form"; @@ -59,6 +62,12 @@ export default async function SettingsPage() { {tSettings("section.culture.title")} + {env.ENABLE_DOCKER && ( + + {(tSettings as unknown as (key: string) => string)("section.docker.title")} + + + )} ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/import-export/docker-discovery-panel.tsx b/apps/nextjs/src/app/[locale]/manage/tools/import-export/docker-discovery-panel.tsx new file mode 100644 index 000000000..a85d7c3b1 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/import-export/docker-discovery-panel.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { Avatar, Badge, Button, Card, Group, Stack, Table, Text, Title } from "@mantine/core"; +import { IconRefresh, IconSearch } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; + +export const DockerDiscoveryPanel = () => { + const tDiscovery = useScopedI18n("docker.discovery" as never) as unknown as ( + key: string, + values?: Record, + ) => string; + const { data: preview, refetch, isLoading: isLoadingPreview, isFetched } = + clientApi.docker.getDiscoveredPreview.useQuery(undefined, { + enabled: false, + }); + const { mutate: syncDiscovery, isPending: isSyncing } = clientApi.docker.syncDiscovery.useMutation({ + onSuccess(result) { + showSuccessNotification({ + title: tDiscovery("sync.notification.success.title"), + message: tDiscovery("sync.notification.success.message", { + created: result.created, + updated: result.updated, + }), + }); + void refetch(); + }, + onError() { + showErrorNotification({ + title: tDiscovery("sync.notification.error.title"), + message: tDiscovery("sync.notification.error.message"), + }); + }, + }); + + const services = preview ?? []; + + return ( + + + + {tDiscovery("title")} + + + + + + + {!isFetched ? ( + + {tDiscovery("preview.idle")} + + ) : services.length === 0 ? ( + + {tDiscovery("preview.empty")} + + ) : ( + + + + {tDiscovery("field.name")} + {tDiscovery("field.group")} + {tDiscovery("field.href")} + {tDiscovery("field.host")} + + + + {services.map((service) => ( + + + + {service.icon && ( + + {service.name.charAt(0)} + + )} + {service.name} + {service.integrationKind && ( + + {service.integrationKind} + + )} + + + + + {service.group} + + + + + {service.href} + + + + + {service.host} + + + + ))} + +
+ )} +
+
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/import-export/export-config-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/import-export/export-config-card.tsx new file mode 100644 index 000000000..0ea4a31e9 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/import-export/export-config-card.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { Alert, Button, Card, Group, List, Loader, Popover, Stack, Text, Title } from "@mantine/core"; +import { IconAlertTriangle, IconDatabaseExport } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; + +import { ConfigSummaryList } from "~/components/config-import/config-summary-list"; + +const exportWarningPointKeys = [ + "management.page.importExport.export.warning.message", + "management.page.importExport.export.warning.storage", + "management.page.importExport.export.warning.sharing", +] as const; + +export const ExportConfigCard = () => { + const t = useI18n() as unknown as (key: string, values?: Record) => string; + const { mutateAsync, isPending } = clientApi.config.exportFull.useMutation(); + const [popoverOpened, setPopoverOpened] = useState(false); + + const { data: preview, isLoading: previewLoading } = clientApi.config.previewExport.useQuery(undefined, { + enabled: popoverOpened, + }); + + const closePopover = () => setPopoverOpened(false); + + const runExport = async () => { + await mutateAsync(undefined, { + onSuccess({ content, filename }) { + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + showSuccessNotification({ + title: t("management.page.importExport.export.notification.success.title"), + message: t("management.page.importExport.export.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("management.page.importExport.export.notification.error.title"), + message: t("management.page.importExport.export.notification.error.message"), + }); + }, + }); + }; + + const handleConfirmExport = () => { + closePopover(); + void runExport(); + }; + + return ( + + + + {t("management.page.importExport.export.title")} + + {t("management.page.importExport.export.description")} + + + + + + + + + + + + } + title={t("management.page.importExport.export.warning.title")} + > + + {exportWarningPointKeys.map((key) => ( + {t(key)} + ))} + + + + + + {t("management.page.importExport.summary.title")} + + {previewLoading && ( + + + + {t("management.page.importExport.preview.exportLoading")} + + + )} + {preview && } + + + + + + + + + + + + ); +}; 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 new file mode 100644 index 000000000..62ec83b34 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/import-export/import-config-card.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Alert, Button, Card, FileInput, Group, List, Stack, Text, Title } from "@mantine/core"; +import { IconCheck, IconFileUpload } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; + +import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel"; + +export const ImportConfigCard = () => { + const t = useI18n() as unknown as (key: string, values?: Record) => string; + const [file, setFile] = useState(null); + const [fileContent, setFileContent] = useState(""); + const [importResult, setImportResult] = useState<{ + boards: number; + apps: number; + integrations: number; + groups: number; + searchEngines: number; + warnings: string[]; + } | null>(null); + + const { + mutate: runPreview, + data: preview, + isPending: previewLoading, + reset: resetPreview, + } = clientApi.config.previewImport.useMutation(); + + const { mutateAsync, isPending } = clientApi.config.importFull.useMutation({ + async onSuccess() { + await revalidatePathActionAsync("/manage"); + }, + }); + + useEffect(() => { + if (!fileContent) { + resetPreview(); + return; + } + runPreview({ content: fileContent }); + }, [fileContent, runPreview, resetPreview]); + + const importAllowed = preview?.compatibility.status === "compatible"; + + const handleImport = async () => { + if (!file || !importAllowed) { + return; + } + const content = await file.text(); + await mutateAsync( + { content }, + { + onSuccess(result) { + setImportResult(result); + showSuccessNotification({ + title: t("management.page.importExport.importConfig.notification.success.title"), + message: t("management.page.importExport.importConfig.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("management.page.importExport.importConfig.notification.error.title"), + message: t("management.page.importExport.importConfig.notification.error.message"), + }); + }, + }, + ); + }; + + return ( + + + + {t("management.page.importExport.importConfig.title")} + + {t("management.page.importExport.importConfig.description")} + + + + } + accept="application/json" + value={file} + onChange={(newFile) => { + setFile(newFile); + setImportResult(null); + setFileContent(""); + + if (!newFile) { + return; + } + + void newFile.text().then((content) => { + setFileContent(content); + }); + }} + label={t("management.page.importExport.importConfig.fileLabel")} + /> + + + + {importResult && ( + }> + + {importResult.boards} boards imported + {importResult.apps} apps created + {importResult.integrations} integrations created + {importResult.groups} groups created + + + )} + + + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/import-export/import-homepage-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/import-export/import-homepage-card.tsx new file mode 100644 index 000000000..affc73812 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/import-export/import-homepage-card.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import { Button, Card, Code, FileInput, Group, Stack, Switch, Text, TextInput, Title } 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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { boardNameSchema } from "@homarr/validation/board"; + +export const ImportHomepageCard = () => { + const t = useI18n() as unknown as (key: string) => string; + 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: string) => [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 allEnvVarsFilled = envVars.length === 0 || envVars.every((v) => (envValues[v] ?? "").trim().length > 0); + + return ( + +
{ + if (!fileValid || !allEnvVarsFilled) return; + + void (async () => { + const resolvedContent = + envVars.length > 0 ? replaceHomepageEnvVariables(rawContent, envValues) : rawContent; + + await mutateAsync( + { + content: resolvedContent, + boardName: values.boardName, + createIntegrations: values.createIntegrations, + }, + { + onSuccess() { + showSuccessNotification({ + title: t("management.page.importExport.importHomepage.notification.success.title"), + message: t("management.page.importExport.importHomepage.notification.success.message"), + }); + form.reset(); + setRawContent(""); + setEnvVars([]); + setEnvValues({}); + }, + onError() { + showErrorNotification({ + title: t("management.page.importExport.importHomepage.notification.error.title"), + message: t("management.page.importExport.importHomepage.notification.error.message"), + }); + }, + }, + ); + })(); + })} + > + + + {t("management.page.importExport.importHomepage.title")} + + {t("management.page.importExport.importHomepage.description")} + + + + } + withAsterisk + accept=".yaml,.yml" + {...form.getInputProps("file")} + error={ + (form.getInputProps("file").error as string | undefined) ?? + (!fileValid && form.isDirty("file") ? t("management.page.importExport.importHomepage.invalidFile") : undefined) + } + type="button" + label={t("management.page.importExport.importHomepage.fileLabel")} + /> + + {envVars.length > 0 && ( + + + {t("management.page.importExport.importHomepage.envVariables.label")} + + + {t("management.page.importExport.importHomepage.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 + /> + ))} + + )} + + + + + + + + + +
+
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/import-export/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/import-export/page.tsx new file mode 100644 index 000000000..f4da80e28 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/import-export/page.tsx @@ -0,0 +1,34 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { DockerDiscoveryPanel } from "./docker-discovery-panel"; +import { ExportConfigCard } from "./export-config-card"; +import { ImportConfigCard } from "./import-config-card"; +import { ImportHomepageCard } from "./import-homepage-card"; + +export default async function ImportExportPage() { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + notFound(); + } + + const t = await getScopedI18n("management.page.importExport" as never) as unknown as (key: string) => string; + + return ( + <> + + + {t("title")} + + + + {env.ENABLE_DOCKER && } + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx index 9e8c569af..de3ac595f 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx @@ -2,7 +2,6 @@ import { 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"; @@ -15,7 +14,7 @@ export const TerminalComponent = () => { const ref = useRef(null); const { activeLevels } = useLogContext(); - const terminalRef = useRef(null); + const terminalRef = useRef(null); clientApi.log.subscribe.useSubscription( { levels: activeLevels, @@ -26,7 +25,6 @@ export const TerminalComponent = () => { terminalRef.current?.refresh(0, terminalRef.current.rows - 1); }, onError(err) { - // This makes sense as logging might cause an infinite loop alert(err); }, }, @@ -37,28 +35,30 @@ export const TerminalComponent = () => { return () => undefined; } - const canvasAddon = new CanvasAddon(); - - terminalRef.current = new Terminal({ + const fitAddon = new FitAddon(); + const terminal = new Terminal({ cursorBlink: false, disableStdin: true, convertEol: true, }); - terminalRef.current.open(ref.current); - terminalRef.current.loadAddon(canvasAddon); + terminal.open(ref.current); + terminal.loadAddon(fitAddon); + terminalRef.current = terminal; - // This is a hack to make sure the terminal is rendered before we try to fit it - // You can blame @Meierschlumpf for this - setTimeout(() => { - const fitAddon = new FitAddon(); - terminalRef.current?.loadAddon(fitAddon); - fitAddon.fit(); - }); + const fitTerminal = () => fitAddon.fit(); + const fitTimeout = setTimeout(fitTerminal); + + const resizeObserver = new ResizeObserver(fitTerminal); + resizeObserver.observe(ref.current); return () => { - terminalRef.current?.dispose(); - canvasAddon.dispose(); + clearTimeout(fitTimeout); + resizeObserver.disconnect(); + fitAddon.dispose(); + terminal.dispose(); + terminalRef.current = null; }; }, []); + return ; }; 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 new file mode 100644 index 000000000..28615aeb8 --- /dev/null +++ b/apps/nextjs/src/components/config-import/config-import-preview-panel.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { Alert, Group, List, Loader, Stack, Text } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import type { ConfigImportPreview } from "@homarr/board-portability"; + +import { ConfigSummaryList } from "./config-summary-list"; + +const compatibilityAlertColor: Record = { + compatible: "blue", + unsupportedVersion: "red", + invalidStructure: "red", +}; + +type ConfigImportPreviewPanelProps = { + preview: ConfigImportPreview | undefined; + previewLoading: boolean; + t: (key: string, values?: Record) => string; +}; + +export const ConfigImportPreviewPanel = ({ preview, previewLoading, t }: ConfigImportPreviewPanelProps) => { + if (!preview && !previewLoading) { + return null; + } + + const importAllowed = preview?.compatibility.status === "compatible"; + + return ( + + {previewLoading && ( + + + + {t("management.page.importExport.preview.importLoading")} + + + )} + + {preview && ( + <> + } + title={ + preview.compatibility.status === "compatible" + ? t("management.page.importExport.preview.migrationNote", { + bundleVersion: preview.meta.bundleVersion || "?", + }) + : t("management.page.importExport.preview.importIncompatible") + } + > + {preview.compatibility.status === "compatible" && preview.meta.exportedAt && ( + + {t("management.page.importExport.preview.importMeta", { + exportedAt: preview.meta.exportedAt, + homarrVersion: preview.meta.homarrVersion, + bundleVersion: preview.meta.bundleVersion, + })} + + )} + {preview.compatibility.issues.length > 0 && ( + + {preview.compatibility.issues.map((issue) => ( + {issue} + ))} + + )} + + + {importAllowed && ( + <> + + + {t("management.page.importExport.preview.importWillCreate")} + + + + + {(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, + })} + + + + )} + + {preview.warnings.length > 0 && ( + }> + + {preview.warnings.map((warning) => ( + {warning} + ))} + + + )} + + )} + + )} + + ); +}; diff --git a/apps/nextjs/src/components/config-import/config-summary-list.tsx b/apps/nextjs/src/components/config-import/config-summary-list.tsx new file mode 100644 index 000000000..5b3b002f7 --- /dev/null +++ b/apps/nextjs/src/components/config-import/config-summary-list.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { List, Text } from "@mantine/core"; + +import type { ConfigEntityCounts } 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); + +type ConfigSummaryListProps = { + counts: ConfigEntityCounts; + t: (key: string, values?: Record) => string; +}; + +export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => { + const entries = countEntries(counts); + + if (entries.length === 0) { + return ( + + {t("management.page.importExport.summary.empty")} + + ); + } + + return ( + + {entries.map((key) => ( + + {t(countLabelKeys[key], { count: counts[key] })} + + ))} + + ); +}; diff --git a/docs/config-portability.md b/docs/config-portability.md new file mode 100644 index 000000000..9b1822142 --- /dev/null +++ b/docs/config-portability.md @@ -0,0 +1,66 @@ +# 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 new file mode 100644 index 000000000..fe3dc00bd --- /dev/null +++ b/examples/docker-compose.yml @@ -0,0 +1,100 @@ +# 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. + +services: + 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.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 + labels: + homarr.name: "Radarr" + homarr.group: "Media" + homarr.href: "http://radarr:7878" + homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" + homarr.description: "Movie management" + 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" + + 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" + homarr.group: "Downloads" + homarr.href: "http://qbittorrent:8080" + homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg" + homarr.description: "Torrent client" + homarr.ping: "http://qbittorrent:8080" + homarr.integration: "qBittorrent" + homarr.widget: "downloads" + homepage.group: "Downloads" + homepage.name: "qBittorrent" + homepage.href: "http://qbittorrent:8080" + homepage.icon: "qbittorrent" + homepage.description: "Torrent client" + +volumes: + sonarr-config: + radarr-config: + qbittorrent-config: diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..81a94b19f --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "24.16.0" diff --git a/packages/api/package.json b/packages/api/package.json index 17d5ae476..31370e44a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@homarr/auth": "workspace:^0.1.0", + "@homarr/board-portability": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0", "@homarr/cron-job-status": "workspace:^0.1.0", diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index a014b4b75..87959fcd5 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -28,6 +28,7 @@ export const appRouter = createTRPCRouter({ certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)), bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)), info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)), + config: lazy(() => import("./router/config").then((mod) => mod.configRouter)), }); // export type definition of API diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 495d410c7..02baac099 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -35,6 +35,12 @@ import { getPermissionsWithParents, widgetKinds, } from "@homarr/definitions"; +import { + exportBoardBundleAsync, + importBoardBundleAsync, + importHomepageServicesAsync, + parseBundleJson, +} from "@homarr/board-portability"; import { importOldmarrAsync } from "@homarr/old-import"; import { importJsonFileSchema } from "@homarr/old-import/shared"; import { oldmarrConfigSchema } from "@homarr/old-schema"; @@ -43,6 +49,9 @@ import { boardChangeVisibilitySchema, boardCreateSchema, boardDuplicateSchema, + boardExportBundleSchema, + boardImportBundleSchema, + boardNameSchema, boardRenameSchema, boardSaveLayoutsSchema, boardSavePartialSettingsSchema, @@ -54,6 +63,7 @@ import { zodUnionFromArray } from "@homarr/validation/enums"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared"; +import packageJson from "../../../../package.json"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { throwIfActionForbiddenAsync } from "./board/board-access"; import { generateResponsiveGridFor } from "./board/grid-algorithm"; @@ -1327,6 +1337,113 @@ export const boardRouter = createTRPCRouter({ const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content)); await importOldmarrAsync(ctx.db, oldmarr, input.configuration); }), + exportBundle: protectedProcedure.input(boardExportBundleSchema).mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view"); + + const result = await exportBoardBundleAsync(ctx.db, input.id, packageJson.version); + if (!result) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Board not found", + }); + } + + return { + filename: result.filename, + content: result.content, + }; + }), + importBundle: permissionRequiredProcedure + .requiresPermission("board-create") + .input(boardImportBundleSchema) + .mutation(async ({ ctx, input }) => { + await noBoardWithSimilarNameAsync(ctx.db, input.name); + + const bundle = parseBundleJson(input.content); + + const existingApps = await ctx.db.query.apps.findMany(); + const existingIntegrations = await ctx.db.query.integrations.findMany(); + + const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all"); + const integrationIdsWithAccess = hasAccessForAll + ? [] + : await ctx.db + .selectDistinct({ + id: integrationGroupPermissions.integrationId, + }) + .from(integrationGroupPermissions) + .leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId)) + .where(eq(groupMembers.userId, ctx.session.user.id)) + .union( + ctx.db + .selectDistinct({ id: integrationUserPermissions.integrationId }) + .from(integrationUserPermissions) + .where(eq(integrationUserPermissions.userId, ctx.session.user.id)), + ) + .then((result) => result.map((row) => row.id)); + + const report = await importBoardBundleAsync( + ctx.db, + bundle, + input.name, + ctx.session.user.id, + existingApps, + existingIntegrations, + integrationIdsWithAccess, + hasAccessForAll, + ); + + return { + boardId: report.boardId, + created: report.created, + warnings: report.warnings, + }; + }), + importHomepageServices: permissionRequiredProcedure + .requiresPermission("board-create") + .input( + z.object({ + content: z.string().min(1), + boardName: boardNameSchema, + createIntegrations: z.boolean().default(true), + }), + ) + .mutation(async ({ ctx, input }) => { + await noBoardWithSimilarNameAsync(ctx.db, input.boardName); + + try { + const report = await importHomepageServicesAsync( + ctx.db, + input.content, + input.boardName, + ctx.session.user.id, + input.createIntegrations, + ); + + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.session.user.id), + columns: { + homeBoardId: true, + }, + }); + + if (!user?.homeBoardId) { + await ctx.db.update(users).set({ homeBoardId: report.boardId }).where(eq(users.id, ctx.session.user.id)); + } + + return { + boardId: report.boardId, + created: report.created, + warnings: report.warnings, + unmappedWidgetTypes: report.unmappedWidgetTypes, + }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error instanceof Error ? error.message : "Could not import Homepage services", + }); + } + }), }); /** diff --git a/packages/api/src/router/config.ts b/packages/api/src/router/config.ts new file mode 100644 index 000000000..a6412f1f6 --- /dev/null +++ b/packages/api/src/router/config.ts @@ -0,0 +1,60 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod/v4"; + +import { + assessBundleCompatibility, + exportFullConfigAsync, + importFullConfigAsync, + previewExportFullConfigAsync, + previewImportFullConfigAsync, +} from "@homarr/board-portability"; + +import packageJson from "../../../../package.json"; +import { createTRPCRouter, permissionRequiredProcedure } from "../trpc"; + +export const configRouter = createTRPCRouter({ + previewExport: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => { + return await previewExportFullConfigAsync(ctx.db); + }), + + previewImport: permissionRequiredProcedure + .requiresPermission("admin") + .input( + z.object({ + content: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version); + }), + + exportFull: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => { + return await exportFullConfigAsync(ctx.db, packageJson.version); + }), + + importFull: permissionRequiredProcedure + .requiresPermission("admin") + .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, 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/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 4527482f0..32d6b12a9 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -3,7 +3,8 @@ import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; import type { Container, ContainerState, Docker, Port } from "@homarr/docker"; -import { DockerSingleton } from "@homarr/docker"; +import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker"; +import { getServerSettingsAsync } from "@homarr/db/queries"; import { dockerContainersRequestHandler } from "@homarr/request-handler/docker"; import { dockerMiddleware } from "../../middlewares/docker"; @@ -104,6 +105,32 @@ export const dockerRouter = createTRPCRouter({ const innerHandler = dockerContainersRequestHandler.handler({}); await innerHandler.invalidateAsync(); }), + getDiscoveredPreview: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .query(async ({ ctx }) => { + const serverSettings = await getServerSettingsAsync(ctx.db); + return await listDiscoveredContainersAsync({ + readHomepageLabels: serverSettings.docker.readHomepageLabels, + }); + }), + syncDiscovery: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .mutation(async ({ ctx }) => { + const serverSettings = await getServerSettingsAsync(ctx.db); + const discovered = await listDiscoveredContainersAsync({ + readHomepageLabels: serverSettings.docker.readHomepageLabels, + }); + + return await syncDiscoveredServicesAsync(discovered, { + targetBoardName: serverSettings.docker.targetBoardName, + enableStatusByDefault: serverSettings.board.enableStatusByDefault, + forceDisableStatus: serverSettings.board.forceDisableStatus, + defaultItemWidth: serverSettings.docker.defaultItemWidth, + defaultItemHeight: serverSettings.docker.defaultItemHeight, + }); + }), }); const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { diff --git a/packages/api/src/router/import/import-router.ts b/packages/api/src/router/import/import-router.ts index 96c5fd039..1dfab7b2f 100644 --- a/packages/api/src/router/import/import-router.ts +++ b/packages/api/src/router/import/import-router.ts @@ -1,5 +1,11 @@ +import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; +import { + assessBundleCompatibility, + importFullConfigAsync, + previewImportFullConfigAsync, +} from "@homarr/board-portability"; import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse"; import { ensureValidTokenOrThrow, @@ -7,6 +13,7 @@ import { importInitialOldmarrInputSchema, } from "@homarr/old-import/import"; +import packageJson from "../../../../../package.json"; import { createTRPCRouter, onboardingProcedure } from "../../trpc"; import { nextOnboardingStepAsync } from "../onboard/onboard-queries"; @@ -40,4 +47,40 @@ export const importRouter = createTRPCRouter({ await importInitialOldmarrAsync(ctx.db, input); await nextOnboardingStepAsync(ctx.db, undefined); }), + previewInitialConfigImport: onboardingProcedure + .requiresStep("import") + .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), + }), + ) + .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, compatibility } = assessBundleCompatibility(parsed, packageJson.version); + if (!bundle || compatibility.status !== "compatible") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: compatibility.issues.join(" "), + }); + } + + 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 941e696e6..a7225b7b4 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -42,6 +42,20 @@ 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/db/queries", () => ({ + getServerSettingsAsync: async () => ({ + docker: { targetBoardName: null, readHomepageLabels: true, pruneRemoved: false }, + board: { enableStatusByDefault: true, forceDisableStatus: false }, + }), +})); const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) => ({ @@ -65,6 +79,8 @@ const validInputs: { restartAll: { ids: ["1"] }, removeAll: { ids: ["1"] }, invalidate: undefined, + getDiscoveredPreview: undefined, + syncDiscovery: undefined, }; describe("All procedures should only be accessible for users with admin permission", () => { diff --git a/packages/board-portability/client.ts b/packages/board-portability/client.ts new file mode 100644 index 000000000..2f447f3c0 --- /dev/null +++ b/packages/board-portability/client.ts @@ -0,0 +1,9 @@ +export { + homarrBundleSchema, + homarrConfigBundleSchema, + type HomarrBundle, + type HomarrConfigBundle, +} from "./src/schema"; +export { parseServicesYaml } from "./src/homepage/parse-services-yaml"; +export { extractHomepageEnvVariables, replaceHomepageEnvVariables } from "./src/homepage/env-variables"; +export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./src/homepage/types"; diff --git a/packages/board-portability/index.ts b/packages/board-portability/index.ts new file mode 100644 index 000000000..d56dededc --- /dev/null +++ b/packages/board-portability/index.ts @@ -0,0 +1,21 @@ +export { + CONFIG_BUNDLE_FORMAT_VERSION, + assessBundleCompatibility, + 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 { exportBoardBundleAsync, bundleFilenameForBoard } from "./src/export"; +export { exportFullConfigAsync } from "./src/export/export-full-config"; +export { previewExportFullConfigAsync } from "./src/export/preview-export-full-config"; +export { parseBundleJson, importBoardBundleAsync, type BundleImportReport } from "./src/import"; +export { importFullConfigAsync, type ConfigImportReport } from "./src/import/import-full-config"; +export { previewImportFullConfigAsync } from "./src/import/plan-full-config-import"; +export { + parseServicesYaml, + importHomepageServicesAsync, + homepageWidgetMap, + type HomepageService, + type HomepageImportReport, + type ParseServicesYamlResult, +} from "./src/homepage"; diff --git a/packages/board-portability/package.json b/packages/board-portability/package.json new file mode 100644 index 000000000..a5ec533e4 --- /dev/null +++ b/packages/board-portability/package.json @@ -0,0 +1,40 @@ +{ + "name": "@homarr/board-portability", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "exports": { + ".": "./index.ts", + "./client": "./client.ts", + "./homepage": "./src/homepage/index.ts" + }, + "scripts": { + "clean": "rm -rf .turbo node_modules", + "format": "oxfmt .", + "lint": "oxlint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@homarr/common": "workspace:^0.1.0", + "@homarr/core": "workspace:^0.1.0", + "@homarr/db": "workspace:^0.1.0", + "@homarr/definitions": "workspace:^0.1.0", + "@homarr/validation": "workspace:^0.1.0", + "superjson": "catalog:", + "yaml": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@homarr/tsconfig": "workspace:^0.1.0", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/board-portability/src/__tests__/bundle-round-trip.spec.ts b/packages/board-portability/src/__tests__/bundle-round-trip.spec.ts new file mode 100644 index 000000000..e973423e5 --- /dev/null +++ b/packages/board-portability/src/__tests__/bundle-round-trip.spec.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "vitest"; + +import type { HomarrBundle } from "../schema"; +import { homarrBundleSchema } from "../schema"; +import { bundleToJson, bundleFilenameForBoard } from "../export/to-json"; +import { parseBundleJson } from "../import/parse-bundle"; + +const createMinimalBundle = (overrides: Partial = {}): HomarrBundle => ({ + version: "1.0", + exportedAt: new Date().toISOString(), + homarrVersion: "1.0.0-test", + boards: [ + { + name: "Test Board", + 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: "Apps", + yOffset: 0, + xOffset: 0, + }, + ], + items: [ + { + ref: "item-1", + kind: "app", + options: { appId: "app-ref-1", openInNewTab: true }, + advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, + integrationRefs: [], + 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: "https://jellyfin.local/health", + }, + ], + integrations: [ + { + ref: "int-ref-1", + kind: "jellyfin", + name: "Jellyfin", + url: "https://jellyfin.local", + secretKinds: ["apiKey"], + secrets: "REDACTED", + }, + ], + ...overrides, +}); + +describe("bundle round-trip", () => { + test("serialized bundle parses back to equal object", () => { + const original = createMinimalBundle(); + const json = bundleToJson(original); + const parsed = parseBundleJson(json); + + expect(parsed.version).toBe(original.version); + expect(parsed.homarrVersion).toBe(original.homarrVersion); + expect(parsed.boards).toHaveLength(1); + expect(parsed.boards[0]?.name).toBe("Test Board"); + expect(parsed.apps).toHaveLength(1); + expect(parsed.apps[0]?.name).toBe("Jellyfin"); + expect(parsed.integrations).toHaveLength(1); + expect(parsed.integrations[0]?.secrets).toBe("REDACTED"); + }); + + test("schema validates valid bundle", () => { + const bundle = createMinimalBundle(); + const result = homarrBundleSchema.safeParse(bundle); + expect(result.success).toBe(true); + }); + + test("schema rejects missing boards", () => { + const invalid = { ...createMinimalBundle(), boards: [] }; + const result = homarrBundleSchema.safeParse(invalid); + expect(result.success).toBe(false); + }); + + test("schema rejects invalid version", () => { + const invalid = { ...createMinimalBundle(), version: "2.0" }; + const result = homarrBundleSchema.safeParse(invalid); + expect(result.success).toBe(false); + }); + + test("bundle preserves multiple items with layouts", () => { + const bundle = createMinimalBundle(); + bundle.boards[0]?.items.push({ + ref: "item-2", + kind: "clock", + options: { is24HourFormat: true }, + advancedOptions: { title: "My Clock", customCssClasses: ["custom"], borderColor: "#000" }, + integrationRefs: [], + layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 2, y: 0, w: 2, h: 1 }], + }); + + const json = bundleToJson(bundle); + const parsed = parseBundleJson(json); + + expect(parsed.boards[0]?.items).toHaveLength(2); + expect(parsed.boards[0]?.items[1]?.kind).toBe("clock"); + expect(parsed.boards[0]?.items[1]?.advancedOptions.title).toBe("My Clock"); + }); + + test("bundle preserves section layouts", () => { + const bundle = createMinimalBundle(); + const firstSection = bundle.boards[0]?.sections[0]; + if (!firstSection) throw new Error("missing section"); + firstSection.layouts = [ + { + layoutRef: "layout-1", + parentSectionRef: "section-1", + xOffset: 0, + yOffset: 0, + width: 10, + height: 5, + }, + ]; + + const json = bundleToJson(bundle); + const parsed = parseBundleJson(json); + + expect(parsed.boards[0]?.sections[0]?.layouts).toHaveLength(1); + expect(parsed.boards[0]?.sections[0]?.layouts?.[0]?.width).toBe(10); + }); + + test("empty apps and integrations are valid", () => { + const bundle = createMinimalBundle({ apps: [], integrations: [] }); + if (bundle.boards[0]) { + bundle.boards[0].items = []; + } + + const json = bundleToJson(bundle); + const parsed = parseBundleJson(json); + + expect(parsed.apps).toHaveLength(0); + expect(parsed.integrations).toHaveLength(0); + }); +}); + +describe("bundleFilenameForBoard", () => { + test("sanitizes board name for filename", () => { + expect(bundleFilenameForBoard("My Board")).toBe("homarr-board-My-Board.json"); + expect(bundleFilenameForBoard("test_board-1")).toBe("homarr-board-test_board-1.json"); + expect(bundleFilenameForBoard("special!@#chars")).toBe("homarr-board-special---chars.json"); + }); +}); diff --git a/packages/board-portability/src/config-bundle-compat.ts b/packages/board-portability/src/config-bundle-compat.ts new file mode 100644 index 000000000..ce75c6264 --- /dev/null +++ b/packages/board-portability/src/config-bundle-compat.ts @@ -0,0 +1,99 @@ +import type { HomarrConfigBundle } from "./schema"; +import { homarrConfigBundleSchema } from "./schema"; + +export const CONFIG_BUNDLE_FORMAT_VERSION = "2.0"; + +export const SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS = [CONFIG_BUNDLE_FORMAT_VERSION] as const; + +export type BundleCompatibilityStatus = "compatible" | "unsupportedVersion" | "invalidStructure"; + +export type BundleCompatibility = { + status: BundleCompatibilityStatus; + bundleVersion: string | null; + bundleHomarrVersion: string | null; + currentHomarrVersion: string; + issues: string[]; +}; + +const versionSupported = (version: string) => + (SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS as readonly string[]).includes(version); + +export const parseConfigBundleJson = (content: string): HomarrConfigBundle => { + const parsed: unknown = JSON.parse(content); + return homarrConfigBundleSchema.parse(parsed); +}; + +export const assessBundleCompatibility = ( + raw: unknown, + currentHomarrVersion: string, +): { bundle: HomarrConfigBundle | null; compatibility: BundleCompatibility } => { + const issues: string[] = []; + + if (raw === null || typeof raw !== "object") { + return { + bundle: null, + compatibility: { + status: "invalidStructure", + bundleVersion: null, + bundleHomarrVersion: null, + currentHomarrVersion, + issues: ["File is not a valid JSON object"], + }, + }; + } + + const record = raw as Record; + const bundleVersion = typeof record.version === "string" ? record.version : null; + const bundleHomarrVersion = typeof record.homarrVersion === "string" ? record.homarrVersion : null; + + if (bundleVersion && !versionSupported(bundleVersion)) { + issues.push( + `Bundle format version "${bundleVersion}" is not supported. This instance expects version ${CONFIG_BUNDLE_FORMAT_VERSION}.`, + ); + return { + bundle: null, + compatibility: { + status: "unsupportedVersion", + bundleVersion, + bundleHomarrVersion, + currentHomarrVersion, + issues, + }, + }; + } + + const parseResult = homarrConfigBundleSchema.safeParse(raw); + if (!parseResult.success) { + issues.push("Bundle structure does not match the current Homarr config schema."); + issues.push("Ensure the exporting instance was upgraded and migrations have run before export."); + return { + bundle: null, + compatibility: { + status: "invalidStructure", + bundleVersion, + bundleHomarrVersion, + currentHomarrVersion, + issues, + }, + }; + } + + const bundle = parseResult.data; + + if (bundle.homarrVersion !== currentHomarrVersion) { + issues.push( + `Exported from Homarr ${bundle.homarrVersion}; this instance is ${currentHomarrVersion}. Import is allowed when the bundle format matches.`, + ); + } + + return { + bundle, + compatibility: { + status: "compatible", + bundleVersion: bundle.version, + bundleHomarrVersion: bundle.homarrVersion, + currentHomarrVersion, + issues, + }, + }; +}; diff --git a/packages/board-portability/src/export/export-full-config.ts b/packages/board-portability/src/export/export-full-config.ts new file mode 100644 index 000000000..2ecd7f7a9 --- /dev/null +++ b/packages/board-portability/src/export/export-full-config.ts @@ -0,0 +1,214 @@ +import SuperJSON from "superjson"; + +import { env } from "@homarr/common/env"; +import type { Database } from "@homarr/db"; +import { + apps, + boardGroupPermissions, + boards, + groupPermissions, + groups, + integrationGroupPermissions, + integrations, + integrationSecrets, + searchEngines, + serverSettings, +} 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 { parseStoredValue, replaceAppIdsInValue } from "../utils"; +import { loadBoardGraphAsync } from "./load-board-graph"; + +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, +}); + +export const exportFullConfigAsync = async ( + db: Database, + homarrVersion: string, +): Promise<{ content: string; filename: string }> => { + const allBoards = await db.query.boards.findMany(); + const allApps = await db.query.apps.findMany(); + const allIntegrations = await db.select().from(integrations); + const allSecrets = await db.select().from(integrationSecrets); + const allServerSettings = await db.select().from(serverSettings); + const allSearchEngines = await db.select().from(searchEngines); + const allGroups = await db.select().from(groups); + const allGroupPermissions = await db.select().from(groupPermissions); + const allBoardGroupPermissions = await db.select().from(boardGroupPermissions); + const allIntegrationGroupPermissions = await db.select().from(integrationGroupPermissions); + + 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 bundleIntegrations = allIntegrations.map((integration) => ({ + ref: integration.id, + kind: integration.kind, + name: integration.name, + url: integration.url, + secrets: secretsByIntegration.get(integration.id) ?? [], + })); + + const bundleApps: HomarrBundleApp[] = allApps.map((app) => ({ + ref: app.id, + name: app.name, + href: app.href, + iconUrl: app.iconUrl, + description: app.description, + pingUrl: app.pingUrl, + })); + + const appIdSet = new Set(allApps.map((a) => a.id)); + const appIdToRef = new Map(allApps.map((app) => [app.id, app.id] as const)); + + const bundleBoards = []; + for (const boardRow of allBoards) { + const boardGraph = await loadBoardGraphAsync(db, boardRow.id); + if (!boardGraph) continue; + + const bundleItems = boardGraph.items.map((item) => { + const options = parseStoredValue>(item.options, {}); + const advancedOptions = itemAdvancedOptionsSchema.parse( + parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})), + ); + const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record; + const appRef = typeof options.appId === "string" && appIdSet.has(options.appId) ? options.appId : undefined; + + return { + ref: item.id, + kind: item.kind, + options: portableOptions, + advancedOptions, + integrationRefs: item.integrations.map(({ integrationId }) => integrationId), + appRef, + layouts: item.layouts.map((layout) => ({ + layoutRef: layout.layoutId, + sectionRef: layout.sectionId, + x: layout.xOffset, + y: layout.yOffset, + w: layout.width, + h: layout.height, + })), + }; + }); + + bundleBoards.push({ + name: boardGraph.name, + settings: buildBoardSettings(boardGraph), + layouts: boardGraph.layouts.map((layout) => ({ + ref: layout.id, + name: layout.name, + columnCount: layout.columnCount, + breakpoint: layout.breakpoint, + })), + sections: boardGraph.sections.map((section) => ({ + ref: section.id, + kind: section.kind, + name: section.name, + yOffset: section.yOffset, + xOffset: section.xOffset, + options: section.options ? (parseStoredValue(section.options, {}) as Record) : undefined, + layouts: + section.layouts.length > 0 + ? section.layouts.map((layout) => ({ + layoutRef: layout.layoutId, + parentSectionRef: layout.parentSectionId ?? section.id, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + })) + : undefined, + })), + items: bundleItems, + }); + } + + const settingsRecord: Record = {}; + for (const setting of allServerSettings) { + settingsRecord[setting.settingKey] = SuperJSON.parse(setting.value); + } + + const bundleSearchEngines = allSearchEngines.map((se) => ({ + ref: se.id, + iconUrl: se.iconUrl, + name: se.name, + short: se.short, + description: se.description, + urlTemplate: se.urlTemplate, + type: se.type, + 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 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) ?? [], + })); + + const bundle: HomarrConfigBundle = { + version: CONFIG_BUNDLE_FORMAT_VERSION, + type: "full-config", + exportedAt: new Date().toISOString(), + homarrVersion, + encryptionKey: env.SECRET_ENCRYPTION_KEY ?? "0".repeat(64), + boards: bundleBoards, + apps: bundleApps, + integrations: bundleIntegrations, + serverSettings: settingsRecord, + searchEngines: bundleSearchEngines, + groups: bundleGroups, + }; + + const now = new Date().toISOString().slice(0, 10); + return { + content: JSON.stringify(bundle, null, 2), + filename: `homarr-config-${now}.json`, + }; +}; diff --git a/packages/board-portability/src/export/index.ts b/packages/board-portability/src/export/index.ts new file mode 100644 index 000000000..993331b5e --- /dev/null +++ b/packages/board-portability/src/export/index.ts @@ -0,0 +1,23 @@ +import type { Database } from "@homarr/db"; + +import { loadBoardGraphAsync } from "./load-board-graph"; +import { serializeBoardToBundleAsync } from "./serialize-board"; +import { bundleFilenameForBoard, bundleToJson } from "./to-json"; + +export const exportBoardBundleAsync = async (db: Database, boardId: string, homarrVersion: string) => { + const board = await loadBoardGraphAsync(db, boardId); + if (!board) { + return null; + } + + const bundle = await serializeBoardToBundleAsync(db, board, homarrVersion); + return { + bundle, + filename: bundleFilenameForBoard(board.name), + content: bundleToJson(bundle), + }; +}; + +export { loadBoardGraphAsync } from "./load-board-graph"; +export { serializeBoardToBundleAsync } from "./serialize-board"; +export { bundleToJson, bundleFilenameForBoard } from "./to-json"; diff --git a/packages/board-portability/src/export/load-board-graph.ts b/packages/board-portability/src/export/load-board-graph.ts new file mode 100644 index 000000000..ece2b2bc4 --- /dev/null +++ b/packages/board-portability/src/export/load-board-graph.ts @@ -0,0 +1,35 @@ +import { eq } from "@homarr/db"; +import type { Database } from "@homarr/db"; +import { boards } from "@homarr/db/schema"; + +export const loadBoardGraphAsync = async (db: Database, boardId: string) => { + const board = await db.query.boards.findFirst({ + where: eq(boards.id, boardId), + with: { + layouts: true, + sections: { + with: { + layouts: true, + }, + }, + items: { + with: { + layouts: true, + integrations: { + with: { + integration: { + with: { + secrets: true, + }, + }, + }, + }, + }, + }, + }, + }); + + return board; +}; + +export type LoadedBoardGraph = NonNullable>>; diff --git a/packages/board-portability/src/export/preview-export-full-config.ts b/packages/board-portability/src/export/preview-export-full-config.ts new file mode 100644 index 000000000..49fec76ee --- /dev/null +++ b/packages/board-portability/src/export/preview-export-full-config.ts @@ -0,0 +1,42 @@ +import type { Database } from "@homarr/db"; +import { + apps, + boards, + groups, + integrationSecrets, + integrations, + items, + layouts, + searchEngines, + sections, + serverSettings, +} from "@homarr/db/schema"; + +import type { ConfigExportPreview } from "../types"; + +export const previewExportFullConfigAsync = async (db: Database): Promise => { + const allBoards = await db.query.boards.findMany(); + const allApps = await db.query.apps.findMany(); + const allIntegrations = await db.select().from(integrations); + const allSecrets = await db.select().from(integrationSecrets); + const allItems = await db.select().from(items); + const allSections = await db.select().from(sections); + const allLayouts = await db.select().from(layouts); + const allSearchEngines = await db.select().from(searchEngines); + const allGroups = await db.select().from(groups); + const allServerSettings = await db.select().from(serverSettings); + + return { + boards: allBoards.length, + apps: allApps.length, + integrations: allIntegrations.length, + secrets: allSecrets.length, + widgets: allItems.length, + sections: allSections.length, + layouts: allLayouts.length, + searchEngines: allSearchEngines.length, + groups: allGroups.length, + serverSettings: allServerSettings.length, + // itemLayouts tracked implicitly via widgets; layouts count covers breakpoints + }; +}; diff --git a/packages/board-portability/src/export/serialize-board.ts b/packages/board-portability/src/export/serialize-board.ts new file mode 100644 index 000000000..04f865a1e --- /dev/null +++ b/packages/board-portability/src/export/serialize-board.ts @@ -0,0 +1,168 @@ +import { inArray } from "@homarr/db"; +import type { Database } from "@homarr/db"; +import { apps } from "@homarr/db/schema"; +import type { IntegrationSecretKind } from "@homarr/definitions"; +import { itemAdvancedOptionsSchema } from "@homarr/validation/shared"; + +import type { HomarrBundle, HomarrBundleApp, HomarrBundleIntegration } from "../schema"; +import { parseStoredValue, replaceAppIdsInValue } from "../utils"; +import type { LoadedBoardGraph } from "./load-board-graph"; + +const buildBoardSettings = (board: LoadedBoardGraph) => ({ + 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, +}); + +const walkValues = (value: unknown, onString: (candidate: string) => void) => { + if (typeof value === "string") { + onString(value); + return; + } + if (Array.isArray(value)) { + value.forEach((entry) => walkValues(entry, onString)); + return; + } + if (value !== null && typeof value === "object") { + Object.values(value).forEach((entry) => walkValues(entry, onString)); + } +}; + +export const serializeBoardToBundleAsync = async ( + db: Database, + board: LoadedBoardGraph, + homarrVersion: string, +): Promise => { + const appIdCandidates = new Set(); + const integrationRecords = new Map< + string, + { + id: string; + kind: LoadedBoardGraph["items"][number]["integrations"][number]["integration"]["kind"]; + name: string; + url: string; + secretKinds: IntegrationSecretKind[]; + } + >(); + + board.items.forEach((item) => { + const options = parseStoredValue>(item.options, {}); + walkValues(options, (candidate) => appIdCandidates.add(candidate)); + + item.integrations.forEach(({ integration }) => { + integrationRecords.set(integration.id, { + id: integration.id, + kind: integration.kind, + name: integration.name, + url: integration.url, + secretKinds: integration.secrets.map((secret) => secret.kind), + }); + }); + }); + + const dbApps = + appIdCandidates.size > 0 + ? await db.query.apps.findMany({ + where: inArray(apps.id, [...appIdCandidates]), + }) + : []; + + const appIdToRef = new Map(dbApps.map((app) => [app.id, app.id] as const)); + + const bundleApps: HomarrBundleApp[] = dbApps.map((app) => ({ + ref: app.id, + name: app.name, + href: app.href, + iconUrl: app.iconUrl, + description: app.description, + pingUrl: app.pingUrl, + })); + + const bundleIntegrations: HomarrBundleIntegration[] = [...integrationRecords.values()].map((integration) => ({ + ref: integration.id, + kind: integration.kind, + name: integration.name, + url: integration.url, + secretKinds: integration.secretKinds, + secrets: "REDACTED" as const, + })); + + const bundleItems = board.items.map((item) => { + const options = parseStoredValue>(item.options, {}); + const advancedOptions = itemAdvancedOptionsSchema.parse( + parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})), + ); + const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record; + const appRef = typeof options.appId === "string" ? appIdToRef.get(options.appId) : undefined; + + return { + ref: item.id, + kind: item.kind, + options: portableOptions, + advancedOptions, + integrationRefs: item.integrations.map(({ integrationId }) => integrationId), + appRef, + layouts: item.layouts.map((layout) => ({ + layoutRef: layout.layoutId, + sectionRef: layout.sectionId, + x: layout.xOffset, + y: layout.yOffset, + w: layout.width, + h: layout.height, + })), + }; + }); + + return { + version: "1.0", + exportedAt: new Date().toISOString(), + homarrVersion, + boards: [ + { + name: board.name, + settings: buildBoardSettings(board), + layouts: board.layouts.map((layout) => ({ + ref: layout.id, + name: layout.name, + columnCount: layout.columnCount, + breakpoint: layout.breakpoint, + })), + sections: board.sections.map((section) => ({ + ref: section.id, + kind: section.kind, + name: section.name, + yOffset: section.yOffset, + xOffset: section.xOffset, + options: section.options ? (parseStoredValue(section.options, {}) as Record) : undefined, + layouts: + section.layouts.length > 0 + ? section.layouts.map((layout) => ({ + layoutRef: layout.layoutId, + parentSectionRef: layout.parentSectionId ?? section.id, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + })) + : undefined, + })), + items: bundleItems, + }, + ], + apps: bundleApps, + integrations: bundleIntegrations, + }; +}; diff --git a/packages/board-portability/src/export/to-json.ts b/packages/board-portability/src/export/to-json.ts new file mode 100644 index 000000000..91f27075f --- /dev/null +++ b/packages/board-portability/src/export/to-json.ts @@ -0,0 +1,10 @@ +import type { HomarrBundle } from "../schema"; + +export const bundleToJson = (bundle: HomarrBundle) => { + return JSON.stringify(bundle, null, 2); +}; + +export const bundleFilenameForBoard = (boardName: string) => { + const sanitized = boardName.replace(/[^A-Za-z0-9-_]/g, "-"); + return `homarr-board-${sanitized}.json`; +}; diff --git a/packages/board-portability/src/homepage/env-variables.ts b/packages/board-portability/src/homepage/env-variables.ts new file mode 100644 index 000000000..b1143c4b3 --- /dev/null +++ b/packages/board-portability/src/homepage/env-variables.ts @@ -0,0 +1,15 @@ +const ENV_VAR_PATTERN = /\{\{(HOMEPAGE_VAR_[A-Z0-9_]+)\}\}/g; + +export const extractHomepageEnvVariables = (content: string): string[] => { + const matches = new Set(); + for (const match of content.matchAll(ENV_VAR_PATTERN)) { + matches.add(match[1] ?? ""); + } + return [...matches].sort(); +}; + +export const replaceHomepageEnvVariables = ( + content: string, + values: Record, +): string => + content.replaceAll(ENV_VAR_PATTERN, (full, varName: string) => values[varName] ?? full); diff --git a/packages/board-portability/src/homepage/import.ts b/packages/board-portability/src/homepage/import.ts new file mode 100644 index 000000000..27825fc3a --- /dev/null +++ b/packages/board-portability/src/homepage/import.ts @@ -0,0 +1,76 @@ +import { handleTransactionsAsync } from "@homarr/db"; +import type { Database } from "@homarr/db"; +import { createDbInsertCollectionForTransaction } from "@homarr/db/collection"; + +import { parseServicesYaml } from "./parse-services-yaml"; +import { prepareHomepageImport } from "./prepare-homepage-import"; + +export type HomepageImportReport = { + boardId: string; + created: { + apps: number; + integrations: number; + items: number; + sections: number; + }; + warnings: string[]; + unmappedWidgetTypes: string[]; +}; + +export const importHomepageServicesAsync = async ( + db: Database, + content: string, + boardName: string, + creatorId: string, + createIntegrations: boolean, +): Promise => { + const parsed = parseServicesYaml(content); + if (!parsed.success) { + throw new Error(parsed.error); + } + + const prepared = prepareHomepageImport(parsed.services, boardName, creatorId, createIntegrations); + + const insertCollection = createDbInsertCollectionForTransaction([ + "apps", + "integrations", + "integrationSecrets", + "boards", + "layouts", + "sections", + "items", + "itemLayouts", + "integrationItems", + ]); + + insertCollection.apps.push(...prepared.apps); + insertCollection.integrations.push(...prepared.integrations); + insertCollection.integrationSecrets.push(...prepared.integrationSecrets); + insertCollection.boards.push(prepared.board); + insertCollection.layouts.push(...prepared.layouts); + insertCollection.sections.push(...prepared.sections); + insertCollection.items.push(...prepared.items); + insertCollection.itemLayouts.push(...prepared.itemLayouts); + insertCollection.integrationItems.push(...prepared.integrationItems); + + await handleTransactionsAsync(db, { + async handleAsync(innerDb) { + await insertCollection.insertAllAsync(innerDb); + }, + handleSync(innerDb) { + insertCollection.insertAll(innerDb); + }, + }); + + return { + boardId: prepared.board.id, + created: { + apps: prepared.apps.length, + integrations: prepared.integrations.length, + items: prepared.items.length, + sections: prepared.sections.length, + }, + warnings: prepared.warnings, + unmappedWidgetTypes: prepared.unmappedWidgetTypes, + }; +}; diff --git a/packages/board-portability/src/homepage/index.ts b/packages/board-portability/src/homepage/index.ts new file mode 100644 index 000000000..09cc602a5 --- /dev/null +++ b/packages/board-portability/src/homepage/index.ts @@ -0,0 +1,5 @@ +export { parseServicesYaml } from "./parse-services-yaml"; +export { homepageWidgetMap, type HomepageWidgetMapping } from "./widget-type-map"; +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 new file mode 100644 index 000000000..3b5e6567e --- /dev/null +++ b/packages/board-portability/src/homepage/parse-services-yaml.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "vitest"; + +import { parseServicesYaml } from "./parse-services-yaml"; + +const sampleYaml = ` +- Media: + - Sonarr: + icon: sonarr.png + href: http://sonarr.host/ + description: Series management + ping: sonarr.host + widget: + type: sonarr + url: http://sonarr.host + key: apikeyhere + - Radarr: + icon: radarr.png + href: http://radarr.host/ + widgets: + - type: radarr + url: http://radarr.host + key: radarrkey +- Downloads: + - qBittorrent: + icon: qbittorrent.png + href: http://qbit.host/ + widget: + type: qbittorrent + url: http://qbit.host + username: admin + password: pass123 +`; + +describe("parseServicesYaml", () => { + test("parses groups, services, singular widget, and plural widgets", () => { + const result = parseServicesYaml(sampleYaml); + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + expect(result.services).toHaveLength(3); + + const sonarr = result.services.find((service) => service.name === "Sonarr"); + expect(sonarr).toMatchObject({ + group: "Media", + href: "http://sonarr.host/", + ping: "sonarr.host", + }); + expect(sonarr?.widgets).toHaveLength(1); + expect(sonarr?.widgets[0]).toMatchObject({ type: "sonarr", key: "apikeyhere" }); + + const radarr = result.services.find((service) => service.name === "Radarr"); + expect(radarr?.widgets).toHaveLength(1); + expect(radarr?.widgets[0]?.type).toBe("radarr"); + + const qbit = result.services.find((service) => service.name === "qBittorrent"); + expect(qbit?.group).toBe("Downloads"); + expect(qbit?.widgets[0]).toMatchObject({ type: "qbittorrent", username: "admin" }); + }); + + test("flattens nested groups with separator", () => { + const yaml = ` +- Parent: + - Child: + - Service: + href: http://service.local +`; + const result = parseServicesYaml(yaml); + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + expect(result.services[0]?.group).toBe("Parent / Child"); + expect(result.services[0]?.name).toBe("Service"); + }); + + test("parses services without widgets", () => { + const yaml = ` +- Tools: + - Homepage: + href: http://homepage.local +`; + const result = parseServicesYaml(yaml); + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + expect(result.services[0]?.widgets).toEqual([]); + }); + + test("returns empty services for empty content", () => { + expect(parseServicesYaml(" ")).toEqual({ success: true, services: [] }); + }); + + test("handles malformed yaml gracefully", () => { + const result = parseServicesYaml("invalid: yaml: ["); + expect(result.success).toBe(false); + if (result.success) { + return; + } + expect(result.error).toBe("Invalid YAML"); + }); + + test("rejects non-array root documents", () => { + const result = parseServicesYaml("name: not-an-array"); + expect(result).toEqual({ success: false, error: "Homepage services.yaml must be a YAML array" }); + }); +}); diff --git a/packages/board-portability/src/homepage/parse-services-yaml.ts b/packages/board-portability/src/homepage/parse-services-yaml.ts new file mode 100644 index 000000000..66fd0ddbe --- /dev/null +++ b/packages/board-portability/src/homepage/parse-services-yaml.ts @@ -0,0 +1,111 @@ +import { parse as parseYaml } from "yaml"; + +import type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types"; + +const extractWidgets = (config: Record): HomepageWidget[] => { + const singular = config.widget; + const plural = config.widgets; + + if (Array.isArray(plural)) { + return plural.filter(isWidgetConfig).map(normalizeWidget); + } + + if (isWidgetConfig(singular)) { + return [normalizeWidget(singular)]; + } + + return []; +}; + +const isWidgetConfig = (value: unknown): value is Record & { type: string } => { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "type" in value && + typeof value.type === "string" + ); +}; + +const normalizeWidget = (widget: Record): HomepageWidget => { + const { type, url, key, ...rest } = widget; + return { + type: String(type), + ...(typeof url === "string" ? { url } : {}), + ...(typeof key === "string" ? { key } : {}), + ...rest, + }; +}; + +const isServiceConfig = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +const parseService = (group: string, name: string, config: Record): HomepageService => { + const icon = typeof config.icon === "string" ? config.icon : undefined; + const href = typeof config.href === "string" ? config.href : undefined; + const description = typeof config.description === "string" ? config.description : undefined; + const ping = typeof config.ping === "string" ? config.ping : undefined; + const siteMonitor = typeof config.siteMonitor === "string" ? config.siteMonitor : undefined; + + return { + group, + name, + icon, + href, + description, + ping, + siteMonitor, + widgets: extractWidgets(config), + }; +}; + +const collectServices = (entries: unknown[], groupPath: string): HomepageService[] => { + const services: HomepageService[] = []; + + for (const entry of entries) { + if (!isServiceConfig(entry)) { + continue; + } + + const keys = Object.keys(entry); + if (keys.length !== 1) { + continue; + } + + const name = keys[0] ?? ""; + const value = entry[name]; + + if (Array.isArray(value)) { + const nestedGroup = groupPath ? `${groupPath} / ${name}` : name; + services.push(...collectServices(value, nestedGroup)); + continue; + } + + if (isServiceConfig(value)) { + services.push(parseService(groupPath.length > 0 ? groupPath : "Services", name, value)); + } + } + + return services; +}; + +export const parseServicesYaml = (content: string): ParseServicesYamlResult => { + const trimmed = content.trim(); + if (trimmed.length === 0) { + return { success: true, services: [] }; + } + + let parsed: unknown; + try { + parsed = parseYaml(trimmed); + } catch { + return { success: false, error: "Invalid YAML" }; + } + + if (!Array.isArray(parsed)) { + return { success: false, error: "Homepage services.yaml must be a YAML array" }; + } + + return { success: true, services: collectServices(parsed, "") }; +}; diff --git a/packages/board-portability/src/homepage/prepare-homepage-import.ts b/packages/board-portability/src/homepage/prepare-homepage-import.ts new file mode 100644 index 000000000..b1636d5c1 --- /dev/null +++ b/packages/board-portability/src/homepage/prepare-homepage-import.ts @@ -0,0 +1,299 @@ +import { createId } from "@homarr/common"; +import { encryptSecret } from "@homarr/common/server"; +import type { InferInsertModel } from "@homarr/db"; +import type { + apps, + boards, + integrationItems, + integrationSecrets, + integrations, + itemLayouts, + items, + layouts, + sections, +} from "@homarr/db/schema"; +import type { IntegrationSecretKind } from "@homarr/definitions"; +import { emptySuperJSON, 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; + +export type PreparedHomepageImport = { + apps: InferInsertModel[]; + integrations: InferInsertModel[]; + integrationSecrets: InferInsertModel[]; + board: InferInsertModel; + layouts: InferInsertModel[]; + sections: InferInsertModel[]; + items: InferInsertModel[]; + itemLayouts: InferInsertModel[]; + integrationItems: InferInsertModel[]; + warnings: string[]; + unmappedWidgetTypes: string[]; +}; + +const isEnvVarReference = (value: string) => ENV_VAR_PATTERN.test(value.trim()); + +export const resolveHomepageIconUrl = (icon: string | undefined, serviceName: string): string => { + if (!icon || icon.trim().length === 0) { + const fallback = serviceName.toLowerCase().replaceAll(/[^a-z0-9]+/g, ""); + return `${DASHBOARD_ICON_CDN}/${fallback || "generic"}.svg`; + } + + if (icon.startsWith("http://") || icon.startsWith("https://")) { + return icon; + } + + const baseName = icon.replace(/\.(png|webp|svg|jpg|jpeg)$/i, ""); + return `${DASHBOARD_ICON_CDN}/${baseName}.svg`; +}; + +const resolvePingUrl = (service: HomepageService): string | null => { + const raw = service.ping ?? service.siteMonitor; + if (!raw || raw.trim().length === 0) { + return null; + } + + if (raw.startsWith("http://") || raw.startsWith("https://")) { + return raw; + } + + return `http://${raw}`; +}; + +const extractSecretValue = (widget: HomepageWidget, homepageField: string): string | undefined => { + const value = widget[homepageField]; + return typeof value === "string" && value.length > 0 ? value : undefined; +}; + +export const prepareHomepageImport = ( + services: HomepageService[], + boardName: string, + creatorId: string, + createIntegrations: boolean, +): PreparedHomepageImport => { + const warnings: string[] = []; + const unmappedWidgetTypesSet = new Set(); + + const boardId = createId(); + const layoutId = createId(); + + const board: InferInsertModel = { + id: boardId, + name: boardName, + creatorId, + isPublic: false, + }; + + const layoutRows: InferInsertModel[] = [ + { + id: layoutId, + boardId, + name: "Base", + columnCount: COLUMN_COUNT, + breakpoint: 0, + }, + ]; + + const groupNames = [...new Set(services.map((service) => service.group || "Services"))]; + const sectionByGroup = new Map(); + const sectionRows: InferInsertModel[] = groupNames.map((groupName, index) => { + const sectionId = createId(); + sectionByGroup.set(groupName, sectionId); + return { + id: sectionId, + boardId, + kind: "category" as const, + name: groupName.length > 0 ? groupName : "Services", + xOffset: 0, + yOffset: index, + }; + }); + + const appRows: InferInsertModel[] = []; + const integrationRows: InferInsertModel[] = []; + const integrationSecretRows: InferInsertModel[] = []; + const itemRows: InferInsertModel[] = []; + const itemLayoutRows: InferInsertModel[] = []; + const integrationItemRows: InferInsertModel[] = []; + + const gridState = new Map(); + + for (const service of services) { + const groupKey = service.group || "Services"; + const sectionId = sectionByGroup.get(groupKey); + if (!sectionId) { + warnings.push(`Skipped "${service.name}" because section "${groupKey}" was not found`); + continue; + } + + const appId = createId(); + const iconUrl = resolveHomepageIconUrl(service.icon, service.name); + const pingUrl = resolvePingUrl(service); + + appRows.push({ + id: appId, + name: service.name, + href: service.href ?? null, + iconUrl, + description: service.description ?? null, + pingUrl, + }); + + const grid = gridState.get(sectionId) ?? { x: 0, y: 0 }; + const itemId = createId(); + + itemRows.push({ + id: itemId, + boardId, + kind: "app", + options: stringifyForDb({ + appId, + openInNewTab: true, + showTitle: true, + layout: "column", + descriptionDisplayMode: service.description ? "tooltip" : "hidden", + pingEnabled: pingUrl !== null, + }), + advancedOptions: emptySuperJSON, + }); + + itemLayoutRows.push({ + itemId, + sectionId, + layoutId, + width: APP_ITEM_WIDTH, + height: APP_ITEM_HEIGHT, + 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); + + if (!createIntegrations) { + for (const widget of service.widgets) { + const mapping = homepageWidgetMap[widget.type.toLowerCase()]; + if (mapping === undefined || mapping === null) { + 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, + }); + } + } + + return { + apps: appRows, + integrations: integrationRows, + integrationSecrets: integrationSecretRows, + board, + layouts: layoutRows, + sections: sectionRows, + items: itemRows, + itemLayouts: itemLayoutRows, + integrationItems: integrationItemRows, + warnings, + unmappedWidgetTypes: [...unmappedWidgetTypesSet].sort(), + }; +}; diff --git a/packages/board-portability/src/homepage/types.ts b/packages/board-portability/src/homepage/types.ts new file mode 100644 index 000000000..797b2711e --- /dev/null +++ b/packages/board-portability/src/homepage/types.ts @@ -0,0 +1,21 @@ +export interface HomepageService { + group: string; + name: string; + icon?: string; + href?: string; + description?: string; + ping?: string; + siteMonitor?: string; + widgets: HomepageWidget[]; +} + +export interface HomepageWidget { + type: string; + url?: string; + key?: string; + [key: string]: unknown; +} + +export type ParseServicesYamlResult = + | { success: true; services: HomepageService[] } + | { success: false; error: string }; diff --git a/packages/board-portability/src/homepage/widget-type-map.spec.ts b/packages/board-portability/src/homepage/widget-type-map.spec.ts new file mode 100644 index 000000000..7d1219bfb --- /dev/null +++ b/packages/board-portability/src/homepage/widget-type-map.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; + +import { integrationDefs, integrationKinds } from "@homarr/definitions"; + +import { homepageWidgetMap } from "./widget-type-map"; + +describe("homepageWidgetMap", () => { + test("returns mapping for known homepage widget types", () => { + const sonarr = homepageWidgetMap.sonarr; + expect(sonarr).toMatchObject({ + integrationKind: "sonarr", + widgetKind: "downloads", + secretFieldMap: { key: "apiKey" }, + }); + }); + + test("returns null for unknown widget types", () => { + expect(homepageWidgetMap.unknownservice).toBeUndefined(); + expect(homepageWidgetMap.bazarr).toBeNull(); + expect(homepageWidgetMap.tautulli).toBeNull(); + }); + + test("only maps integration kinds that exist in integrationDefs", () => { + const invalidKinds: string[] = []; + + for (const mapping of Object.values(homepageWidgetMap)) { + if (!mapping) { + continue; + } + + if (!integrationKinds.includes(mapping.integrationKind)) { + invalidKinds.push(mapping.integrationKind); + } + + expect(integrationDefs[mapping.integrationKind]).toBeDefined(); + } + + expect(invalidKinds).toEqual([]); + }); +}); diff --git a/packages/board-portability/src/homepage/widget-type-map.ts b/packages/board-portability/src/homepage/widget-type-map.ts new file mode 100644 index 000000000..a89ec052e --- /dev/null +++ b/packages/board-portability/src/homepage/widget-type-map.ts @@ -0,0 +1,55 @@ +import type { IntegrationKind, IntegrationSecretKind, WidgetKind } from "@homarr/definitions"; + +export interface HomepageWidgetMapping { + integrationKind: IntegrationKind; + widgetKind: WidgetKind; + secretFieldMap: Record; +} + +export const homepageWidgetMap: Record = { + sonarr: { integrationKind: "sonarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } }, + radarr: { integrationKind: "radarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } }, + lidarr: { integrationKind: "lidarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } }, + readarr: { integrationKind: "readarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } }, + prowlarr: { integrationKind: "prowlarr", widgetKind: "indexerManager", secretFieldMap: { key: "apiKey" } }, + bazarr: null, + jellyfin: { integrationKind: "jellyfin", widgetKind: "mediaServer", secretFieldMap: { key: "apiKey" } }, + emby: { integrationKind: "emby", widgetKind: "mediaServer", secretFieldMap: { key: "apiKey" } }, + plex: { integrationKind: "plex", widgetKind: "mediaServer", secretFieldMap: { key: "apiKey" } }, + jellyseerr: { + integrationKind: "jellyseerr", + widgetKind: "mediaRequests-requestList", + secretFieldMap: { key: "apiKey" }, + }, + overseerr: { + integrationKind: "overseerr", + widgetKind: "mediaRequests-requestList", + secretFieldMap: { key: "apiKey" }, + }, + tautulli: null, + sabnzbd: { integrationKind: "sabNzbd", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } }, + nzbget: { integrationKind: "nzbGet", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } }, + deluge: { integrationKind: "deluge", widgetKind: "downloads", secretFieldMap: { password: "password" } }, + transmission: { + integrationKind: "transmission", + widgetKind: "downloads", + secretFieldMap: { username: "username", password: "password" }, + }, + qbittorrent: { + integrationKind: "qBittorrent", + widgetKind: "downloads", + secretFieldMap: { username: "username", password: "password" }, + }, + pihole: { integrationKind: "piHole", widgetKind: "dnsHoleSummary", secretFieldMap: { key: "apiKey" } }, + adguard: { + integrationKind: "adGuardHome", + widgetKind: "dnsHoleSummary", + secretFieldMap: { username: "username", password: "password" }, + }, + homeassistant: { + integrationKind: "homeAssistant", + widgetKind: "smartHome-entityState", + secretFieldMap: { key: "apiKey" }, + }, + proxmox: { integrationKind: "proxmox", widgetKind: "healthMonitoring", secretFieldMap: {} }, +}; diff --git a/packages/board-portability/src/import/import-full-config.ts b/packages/board-portability/src/import/import-full-config.ts new file mode 100644 index 000000000..cc57d6e2d --- /dev/null +++ b/packages/board-portability/src/import/import-full-config.ts @@ -0,0 +1,392 @@ +import SuperJSON from "superjson"; + +import { createId } from "@homarr/common"; +import { decryptSecretWithKey, encryptSecret } from "@homarr/common/server"; +import type { Database } from "@homarr/db"; +import { eq, handleTransactionsAsync } from "@homarr/db"; +import { createDbInsertCollectionForTransaction } from "@homarr/db/collection"; +import { + apps, + boardGroupPermissions, + boards, + groupPermissions, + groups, + integrationGroupPermissions, + integrations, + integrationSecrets, + itemLayouts, + items, + layouts, + searchEngines, + sectionLayouts, + sections, + serverSettings, +} from "@homarr/db/schema"; +import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; +import type { IntegrationSecretKind } from "@homarr/definitions"; + +import type { HomarrBundleBoard, HomarrConfigBundle } from "../schema"; +import { replaceAppRefsInValue, stringifyForDb } from "../utils"; + +export type ConfigImportReport = { + boards: number; + apps: number; + integrations: number; + groups: 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, + bundle: HomarrConfigBundle, + creatorId: string | null, +): Promise => { + const warnings: 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, + }); + + for (const secret of bundleIntegration.secrets) { + try { + const reEncrypted = reEncryptSecret(secret.value, sourceKey); + secretsToInsert.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}"`); + } + } + } + + const groupRefToId = new Map(); + const groupsToInsert: typeof groups.$inferInsert[] = []; + const groupPermsToInsert: typeof groupPermissions.$inferInsert[] = []; + const boardGroupPermsToInsert: typeof boardGroupPermissions.$inferInsert[] = []; + const integrationGroupPermsToInsert: typeof integrationGroupPermissions.$inferInsert[] = []; + + const boardRefToId = new Map(); + + for (const bundleBoard of bundle.boards) { + if (existingBoardNames.has(bundleBoard.name)) { + warnings.push(`Board "${bundleBoard.name}" already exists and was skipped`); + continue; + } + const boardId = createId(); + boardRefToId.set(bundleBoard.name, boardId); + } + + 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); + } + warnings.push(`Group "${bundleGroup.name}" already exists and was skipped`); + continue; + } + + const newId = createId(); + groupRefToId.set(bundleGroup.ref, newId); + groupsToInsert.push({ + id: newId, + name: bundleGroup.name, + position: bundleGroup.position, + }); + + for (const perm of bundleGroup.permissions) { + groupPermsToInsert.push({ + groupId: newId, + permission: perm as never, + }); + } + } + + const insertCollection = createDbInsertCollectionForTransaction([ + "apps", + "integrations", + "integrationSecrets", + "boards", + "layouts", + "sections", + "sectionLayouts", + "items", + "itemLayouts", + "integrationItems", + "searchEngines", + "groups", + "groupPermissions", + "boardGroupPermissions", + "integrationGroupPermissions", + ]); + + insertCollection.apps.push(...appsToInsert); + insertCollection.integrations.push(...integrationsToInsert); + insertCollection.integrationSecrets.push(...secretsToInsert); + insertCollection.groups.push(...groupsToInsert); + insertCollection.groupPermissions.push(...groupPermsToInsert); + + let boardsCreated = 0; + for (const bundleBoard of bundle.boards) { + const boardId = boardRefToId.get(bundleBoard.name); + if (!boardId) continue; + + boardsCreated++; + const sourceBoard = bundleBoard as HomarrBundleBoard; + const layoutRefToId = new Map(sourceBoard.layouts.map((l) => [l.ref, createId()])); + 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, + }); + + for (const layout of sourceBoard.layouts) { + insertCollection.layouts.push({ + id: mustGet(layoutRefToId, layout.ref), + boardId, + name: layout.name, + columnCount: layout.columnCount, + breakpoint: layout.breakpoint, + }); + } + + for (const section of sourceBoard.sections) { + insertCollection.sections.push({ + id: mustGet(sectionRefToId, section.ref), + boardId, + kind: section.kind, + name: section.name ?? null, + xOffset: section.xOffset ?? null, + yOffset: section.yOffset ?? null, + options: section.options ? stringifyForDb(section.options) : undefined, + }); + + for (const layout of section.layouts ?? []) { + insertCollection.sectionLayouts.push({ + sectionId: mustGet(sectionRefToId, section.ref), + layoutId: mustGet(layoutRefToId, layout.layoutRef), + parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef), + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + }); + } + } + + for (const item of sourceBoard.items) { + const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record; + insertCollection.items.push({ + id: mustGet(itemRefToId, item.ref), + boardId, + kind: item.kind, + options: stringifyForDb(optionsWithRefs), + advancedOptions: stringifyForDb(item.advancedOptions), + }); + + for (const layout of item.layouts) { + insertCollection.itemLayouts.push({ + itemId: mustGet(itemRefToId, item.ref), + sectionId: mustGet(sectionRefToId, layout.sectionRef), + layoutId: mustGet(layoutRefToId, layout.layoutRef), + xOffset: layout.x, + yOffset: layout.y, + width: layout.w, + height: layout.h, + }); + } + + for (const integrationRef of item.integrationRefs) { + const integrationId = integrationRefToId.get(integrationRef); + if (integrationId) { + insertCollection.integrationItems.push({ + itemId: mustGet(itemRefToId, item.ref), + integrationId, + }); + } + } + } + } + + 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; + } + + 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, + }); + } + + for (const bundleGroup of bundle.groups) { + const groupId = groupRefToId.get(bundleGroup.ref); + if (!groupId) continue; + + for (const bp of bundleGroup.boardPermissions) { + const resolvedBoardId = boardRefToId.get(bp.boardRef) ?? bp.boardRef; + boardGroupPermsToInsert.push({ + groupId, + boardId: resolvedBoardId, + permission: bp.permission as never, + }); + } + + for (const ip of bundleGroup.integrationPermissions) { + const resolvedIntegrationId = integrationRefToId.get(ip.integrationRef); + if (resolvedIntegrationId) { + integrationGroupPermsToInsert.push({ + groupId, + integrationId: resolvedIntegrationId, + permission: ip.permission as never, + }); + } + } + } + + insertCollection.boardGroupPermissions.push(...boardGroupPermsToInsert); + insertCollection.integrationGroupPermissions.push(...integrationGroupPermsToInsert); + + await handleTransactionsAsync(db, { + async handleAsync(innerDb) { + await insertCollection.insertAllAsync(innerDb); + }, + handleSync(innerDb) { + insertCollection.insertAll(innerDb); + }, + }); + + 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}"`); + } + } + + return { + boards: boardsCreated, + apps: appsToInsert.length, + integrations: integrationsToInsert.length, + groups: groupsToInsert.length, + searchEngines: bundle.searchEngines.length - warnings.filter((w) => w.includes("Search engine")).length, + warnings, + }; +}; diff --git a/packages/board-portability/src/import/index.ts b/packages/board-portability/src/import/index.ts new file mode 100644 index 000000000..7721724cb --- /dev/null +++ b/packages/board-portability/src/import/index.ts @@ -0,0 +1,4 @@ +export { parseBundleJson } from "./parse-bundle"; +export { prepareBoardImport, type PreparedBundleImport } from "./prepare-board"; +export { importBoardBundleAsync, type BundleImportReport } from "./insert"; +export { doAppsMatch } from "./match-apps"; diff --git a/packages/board-portability/src/import/insert.ts b/packages/board-portability/src/import/insert.ts new file mode 100644 index 000000000..0f337f6c3 --- /dev/null +++ b/packages/board-portability/src/import/insert.ts @@ -0,0 +1,79 @@ +import { handleTransactionsAsync } from "@homarr/db"; +import type { Database } from "@homarr/db"; +import { createDbInsertCollectionForTransaction } from "@homarr/db/collection"; + +import type { HomarrBundle } from "../schema"; +import type { PreparedBundleImport } from "./prepare-board"; +import { prepareBoardImport } from "./prepare-board"; + +export type BundleImportReport = { + created: { + apps: number; + items: number; + }; + warnings: string[]; + boardId: string; +}; + +export const importBoardBundleAsync = async ( + db: Database, + bundle: HomarrBundle, + boardName: string, + creatorId: string, + existingApps: Awaited>, + existingIntegrations: Awaited>, + integrationIdsWithAccess: string[], + hasAccessForAll: boolean, +): Promise => { + const prepared = prepareBoardImport( + bundle, + boardName, + creatorId, + existingApps, + existingIntegrations, + integrationIdsWithAccess, + hasAccessForAll, + ); + + await insertPreparedBundleAsync(db, prepared); + + return { + created: { + apps: prepared.createdAppsCount, + items: prepared.createdItemsCount, + }, + warnings: prepared.warnings, + boardId: prepared.board.id, + }; +}; + +const insertPreparedBundleAsync = async (db: Database, prepared: PreparedBundleImport) => { + const insertCollection = createDbInsertCollectionForTransaction([ + "apps", + "boards", + "layouts", + "sections", + "sectionLayouts", + "items", + "itemLayouts", + "integrationItems", + ]); + + insertCollection.apps.push(...prepared.apps); + insertCollection.boards.push(prepared.board); + insertCollection.layouts.push(...prepared.layouts); + insertCollection.sections.push(...prepared.sections); + insertCollection.sectionLayouts.push(...prepared.sectionLayouts); + insertCollection.items.push(...prepared.items); + insertCollection.itemLayouts.push(...prepared.itemLayouts); + insertCollection.integrationItems.push(...prepared.integrationItems); + + await handleTransactionsAsync(db, { + async handleAsync(innerDb) { + await insertCollection.insertAllAsync(innerDb); + }, + handleSync(innerDb) { + insertCollection.insertAll(innerDb); + }, + }); +}; diff --git a/packages/board-portability/src/import/match-apps.ts b/packages/board-portability/src/import/match-apps.ts new file mode 100644 index 000000000..3bcdc8ee4 --- /dev/null +++ b/packages/board-portability/src/import/match-apps.ts @@ -0,0 +1,14 @@ +import type { InferSelectModel } from "@homarr/db"; +import type { apps } from "@homarr/db/schema"; + +export const doAppsMatch = ( + app1: Omit, "id">, + app2: Omit, "id">, +) => { + return ( + app1.name === app2.name && + app1.iconUrl === app2.iconUrl && + app1.description === app2.description && + app1.href === app2.href + ); +}; diff --git a/packages/board-portability/src/import/parse-bundle.ts b/packages/board-portability/src/import/parse-bundle.ts new file mode 100644 index 000000000..0d82be616 --- /dev/null +++ b/packages/board-portability/src/import/parse-bundle.ts @@ -0,0 +1,7 @@ +import type { HomarrBundle } from "../schema"; +import { homarrBundleSchema } from "../schema"; + +export const parseBundleJson = (content: string): HomarrBundle => { + const parsed: unknown = JSON.parse(content); + return homarrBundleSchema.parse(parsed); +}; diff --git a/packages/board-portability/src/import/plan-full-config-import.ts b/packages/board-portability/src/import/plan-full-config-import.ts new file mode 100644 index 000000000..c21ea515b --- /dev/null +++ b/packages/board-portability/src/import/plan-full-config-import.ts @@ -0,0 +1,196 @@ +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 { 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 integrationMatches = ( + bundleIntegration: HomarrConfigBundle["integrations"][number], + existing: { kind: IntegrationKind; name: string; url: string }, +) => + bundleIntegration.kind === existing.kind && + 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: [], + }; + } + + 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, + }; + } + + 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 existingSettingsByKey = new Map( + (await db.query.serverSettings.findMany()).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 toCreate = emptyCounts(); + let reuseApps = 0; + let reuseIntegrations = 0; + let skipBoards = 0; + let skipGroups = 0; + let skipSearchEngines = 0; + let updateServerSettings = 0; + + for (const bundleApp of bundle.apps) { + const existing = existingApps.find((app) => + doAppsMatch(app, { + name: bundleApp.name, + iconUrl: bundleApp.iconUrl, + description: bundleApp.description ?? null, + href: bundleApp.href, + pingUrl: bundleApp.pingUrl ?? null, + }), + ); + if (existing) { + reuseApps += 1; + continue; + } + toCreate.apps += 1; + } + + for (const bundleIntegration of bundle.integrations) { + const existing = existingIntegrations.find((integration) => integrationMatches(bundleIntegration, integration)); + if (existing) { + reuseIntegrations += 1; + continue; + } + toCreate.integrations += 1; + toCreate.secrets += bundleIntegration.secrets.length; + } + + for (const bundleBoard of bundle.boards) { + if (existingBoardNames.has(bundleBoard.name)) { + skipBoards += 1; + warnings.push(`Board "${bundleBoard.name}" already exists and will be skipped`); + continue; + } + toCreate.boards += 1; + const counts = countBoardEntities(bundleBoard); + toCreate.widgets += counts.widgetCount; + toCreate.sections += counts.sectionCount; + toCreate.layouts += counts.layoutCount; + } + + for (const bundleGroup of bundle.groups) { + if (existingGroupNames.has(bundleGroup.name)) { + skipGroups += 1; + warnings.push(`Group "${bundleGroup.name}" already exists and will be skipped`); + continue; + } + toCreate.groups += 1; + } + + for (const bundleSearchEngine of bundle.searchEngines) { + if (existingSearchEngineShorts.has(bundleSearchEngine.short)) { + skipSearchEngines += 1; + warnings.push(`Search engine "${bundleSearchEngine.name}" already exists and will be skipped`); + continue; + } + toCreate.searchEngines += 1; + } + + for (const [settingKey, value] of Object.entries(bundle.serverSettings)) { + const serialized = SuperJSON.stringify(value); + const existingValue = existingSettingsByKey.get(settingKey); + if (existingValue === undefined) { + toCreate.serverSettings += 1; + continue; + } + if (existingValue !== serialized) { + updateServerSettings += 1; + } + } + + return { + compatibility, + meta: { + exportedAt: bundle.exportedAt, + homarrVersion: bundle.homarrVersion, + bundleVersion: bundle.version, + }, + toCreate, + toReuse: { apps: reuseApps, integrations: reuseIntegrations }, + 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 new file mode 100644 index 000000000..3f570ba9c --- /dev/null +++ b/packages/board-portability/src/import/prepare-board.ts @@ -0,0 +1,233 @@ +import { createId } from "@homarr/common"; +import type { InferInsertModel, InferSelectModel } from "@homarr/db"; +import type { + apps, + boards, + integrationItems, + integrations, + itemLayouts, + items, + layouts, + 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 { 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; + layouts: InferInsertModel[]; + sections: InferInsertModel[]; + sectionLayouts: InferInsertModel[]; + items: InferInsertModel[]; + itemLayouts: InferInsertModel[]; + integrationItems: InferInsertModel[]; + warnings: string[]; + createdAppsCount: number; + createdItemsCount: number; +}; + +const integrationMatches = ( + bundleIntegration: HomarrBundle["integrations"][number], + existing: InferSelectModel, +) => { + return ( + bundleIntegration.kind === existing.kind && + bundleIntegration.name === existing.name && + bundleIntegration.url === existing.url + ); +}; + +export const prepareBoardImport = ( + bundle: HomarrBundle, + boardName: string, + creatorId: string, + existingApps: InferSelectModel[], + existingIntegrations: InferSelectModel[], + integrationIdsWithAccess: string[], + hasAccessForAll: boolean, +): PreparedBundleImport => { + const warnings: string[] = []; + + if (bundle.boards.length > 1) { + warnings.push(`Bundle contains ${bundle.boards.length} boards; only the first board was imported`); + } + + const sourceBoard = bundle.boards[0] as HomarrBundleBoard | undefined; + + if (!sourceBoard) { + throw new Error("Bundle does not contain a board"); + } + + const boardId = createId(); + const layoutRefToId = new Map(sourceBoard.layouts.map((layout) => [layout.ref, createId()])); + const sectionRefToId = new Map(sourceBoard.sections.map((section) => [section.ref, createId()])); + const itemRefToId = new Map(sourceBoard.items.map((item) => [item.ref, createId()])); + + const appRefToId = new Map(); + const appsToInsert: InferInsertModel[] = []; + let createdAppsCount = 0; + + for (const bundleApp of bundle.apps) { + const existingApp = existingApps.find((existing) => + doAppsMatch(existing, { + name: bundleApp.name, + iconUrl: bundleApp.iconUrl, + description: bundleApp.description ?? null, + href: bundleApp.href, + pingUrl: bundleApp.pingUrl ?? null, + }), + ); + + if (existingApp) { + appRefToId.set(bundleApp.ref, existingApp.id); + continue; + } + + const newAppId = createId(); + appRefToId.set(bundleApp.ref, newAppId); + appsToInsert.push({ + id: newAppId, + name: bundleApp.name, + iconUrl: bundleApp.iconUrl, + href: bundleApp.href, + description: bundleApp.description ?? null, + pingUrl: bundleApp.pingUrl ?? null, + }); + createdAppsCount += 1; + } + + const integrationRefToId = new Map(); + for (const bundleIntegration of bundle.integrations) { + const existingIntegration = existingIntegrations.find((existing) => + integrationMatches(bundleIntegration, existing), + ); + + if (!existingIntegration) { + warnings.push( + `Integration "${bundleIntegration.name}" (${bundleIntegration.kind}) was not found and linked widgets were skipped`, + ); + continue; + } + + const hasAccess = hasAccessForAll || integrationIdsWithAccess.includes(existingIntegration.id); + if (!hasAccess) { + warnings.push(`Integration "${bundleIntegration.name}" is not accessible and was skipped`); + continue; + } + + 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 layouts = sourceBoard.layouts.map((layout) => ({ + id: mustGet(layoutRefToId, layout.ref), + boardId, + name: layout.name, + columnCount: layout.columnCount, + breakpoint: layout.breakpoint, + })); + + const sections = sourceBoard.sections.map((section) => ({ + id: mustGet(sectionRefToId, section.ref), + boardId, + kind: section.kind, + name: section.name ?? null, + xOffset: section.xOffset ?? null, + yOffset: section.yOffset ?? null, + options: section.options ? stringifyForDb(section.options) : undefined, + })); + + const sectionLayouts = sourceBoard.sections.flatMap((section) => + (section.layouts ?? []).map((layout) => ({ + sectionId: mustGet(sectionRefToId, section.ref), + layoutId: mustGet(layoutRefToId, layout.layoutRef), + parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef), + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + })), + ); + + const items = sourceBoard.items.map((item) => { + const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record; + return { + id: mustGet(itemRefToId, item.ref), + boardId, + kind: item.kind, + options: stringifyForDb(optionsWithRefs), + advancedOptions: stringifyForDb(item.advancedOptions), + }; + }); + + const itemLayouts = sourceBoard.items.flatMap((item) => + item.layouts.map((layout) => ({ + itemId: mustGet(itemRefToId, item.ref), + sectionId: mustGet(sectionRefToId, layout.sectionRef), + layoutId: mustGet(layoutRefToId, layout.layoutRef), + xOffset: layout.x, + yOffset: layout.y, + width: layout.w, + height: layout.h, + })), + ); + + const integrationItems = sourceBoard.items.flatMap((item) => + item.integrationRefs + .map((integrationRef) => integrationRefToId.get(integrationRef)) + .filter((integrationId): integrationId is string => integrationId !== undefined) + .map((integrationId) => ({ + itemId: mustGet(itemRefToId, item.ref), + integrationId, + })), + ); + + return { + apps: appsToInsert, + board, + layouts, + sections, + sectionLayouts, + items, + itemLayouts, + integrationItems, + warnings, + createdAppsCount, + createdItemsCount: items.length, + }; +}; diff --git a/packages/board-portability/src/schema.ts b/packages/board-portability/src/schema.ts new file mode 100644 index 000000000..a08eec6c8 --- /dev/null +++ b/packages/board-portability/src/schema.ts @@ -0,0 +1,174 @@ +import { z } from "zod/v4"; + +import { + backgroundImageAttachments, + backgroundImageRepeats, + backgroundImageSizes, + integrationKinds, + sectionKinds, + widgetKinds, +} from "@homarr/definitions"; +import { itemAdvancedOptionsSchema } from "@homarr/validation/shared"; +import { zodEnumFromArray } from "@homarr/validation/enums"; + +const bundleVersionSchema = z.literal("1.0"); + +const bundleBoardSettingsSchema = z.object({ + pageTitle: z.string().nullable().optional(), + metaTitle: z.string().nullable().optional(), + logoImageUrl: z.string().nullable().optional(), + faviconImageUrl: z.string().nullable().optional(), + backgroundImageUrl: z.string().nullable().optional(), + backgroundImageAttachment: z.enum(backgroundImageAttachments.values).optional(), + backgroundImageRepeat: z.enum(backgroundImageRepeats.values).optional(), + backgroundImageSize: z.enum(backgroundImageSizes.values).optional(), + primaryColor: z.string(), + secondaryColor: z.string(), + opacity: z.number(), + customCss: z.string().nullable().optional(), + iconColor: z.string().nullable().optional(), + itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]), + disableStatus: z.boolean().optional(), + isPublic: z.boolean().optional(), +}); + +const bundleLayoutSchema = z.object({ + ref: z.string(), + name: z.string(), + columnCount: z.number(), + breakpoint: z.number(), +}); + +const bundleSectionLayoutSchema = z.object({ + layoutRef: z.string(), + parentSectionRef: z.string(), + xOffset: z.number(), + yOffset: z.number(), + width: z.number(), + height: z.number(), +}); + +const bundleSectionSchema = z.object({ + ref: z.string(), + kind: zodEnumFromArray(sectionKinds), + name: z.string().nullable().optional(), + yOffset: z.number().nullable().optional(), + xOffset: z.number().nullable().optional(), + options: z.record(z.string(), z.unknown()).optional(), + layouts: z.array(bundleSectionLayoutSchema).optional(), +}); + +const bundleItemLayoutSchema = z.object({ + layoutRef: z.string(), + sectionRef: z.string(), + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), +}); + +const bundleItemSchema = z.object({ + ref: z.string(), + kind: zodEnumFromArray(widgetKinds), + options: z.record(z.string(), z.unknown()), + advancedOptions: itemAdvancedOptionsSchema, + integrationRefs: z.array(z.string()), + appRef: z.string().optional(), + layouts: z.array(bundleItemLayoutSchema), +}); + +const bundleBoardSchema = z.object({ + name: z.string(), + settings: bundleBoardSettingsSchema, + layouts: z.array(bundleLayoutSchema), + sections: z.array(bundleSectionSchema), + items: z.array(bundleItemSchema), +}); + +const bundleAppSchema = z.object({ + ref: z.string(), + name: z.string(), + href: z.string().nullable(), + iconUrl: z.string(), + description: z.string().nullable().optional(), + pingUrl: z.string().nullable().optional(), +}); + +const bundleIntegrationSchema = z.object({ + ref: z.string(), + kind: zodEnumFromArray(integrationKinds), + name: z.string(), + url: z.string(), + secretKinds: z.array(z.string()), + secrets: z.literal("REDACTED"), +}); + +export const homarrBundleSchema = z.object({ + version: bundleVersionSchema, + exportedAt: z.string(), + homarrVersion: z.string(), + boards: z.array(bundleBoardSchema).min(1), + apps: z.array(bundleAppSchema), + integrations: z.array(bundleIntegrationSchema), +}); + +export type HomarrBundle = z.infer; +export type HomarrBundleBoard = z.infer; +export type HomarrBundleApp = z.infer; +export type HomarrBundleIntegration = z.infer; + +const configBundleSecretSchema = z.object({ + kind: z.string(), + value: z.string(), +}); + +const configBundleIntegrationSchema = z.object({ + ref: z.string(), + kind: zodEnumFromArray(integrationKinds), + name: z.string(), + url: z.string(), + secrets: z.array(configBundleSecretSchema), +}); + +const configBundleSearchEngineSchema = z.object({ + ref: z.string(), + iconUrl: z.string(), + name: z.string(), + short: z.string(), + description: z.string().nullable().optional(), + urlTemplate: z.string().nullable().optional(), + type: z.string(), + integrationRef: z.string().nullable().optional(), +}); + +const configBundleGroupSchema = z.object({ + ref: z.string(), + name: z.string(), + position: z.number(), + permissions: z.array(z.string()), + boardPermissions: z.array( + z.object({ boardRef: z.string(), permission: z.string() }), + ), + integrationPermissions: z.array( + z.object({ integrationRef: z.string(), permission: z.string() }), + ), +}); + +export const homarrConfigBundleSchema = z.object({ + version: z.literal("2.0"), + type: z.literal("full-config"), + exportedAt: z.string(), + homarrVersion: z.string(), + encryptionKey: z.string(), + boards: z.array(bundleBoardSchema), + apps: z.array(bundleAppSchema), + integrations: z.array(configBundleIntegrationSchema), + serverSettings: z.record(z.string(), z.unknown()), + searchEngines: z.array(configBundleSearchEngineSchema), + groups: z.array(configBundleGroupSchema), +}); + +export type HomarrConfigBundle = z.infer; +export type ConfigBundleIntegration = z.infer; +export type ConfigBundleGroup = z.infer; +export type ConfigBundleSearchEngine = z.infer; diff --git a/packages/board-portability/src/types.ts b/packages/board-portability/src/types.ts new file mode 100644 index 000000000..4ed0e2518 --- /dev/null +++ b/packages/board-portability/src/types.ts @@ -0,0 +1,39 @@ +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 type ConfigExportPreview = ConfigEntityCounts; + +export type ConfigImportPreview = { + compatibility: BundleCompatibility; + meta: { + exportedAt: string; + homarrVersion: string; + bundleVersion: string; + }; + toCreate: ConfigEntityCounts; + toReuse: { + apps: number; + integrations: number; + }; + toSkip: { + boards: number; + groups: number; + searchEngines: number; + }; + toUpdate: { + serverSettings: number; + }; + warnings: string[]; +}; diff --git a/packages/board-portability/src/utils.ts b/packages/board-portability/src/utils.ts new file mode 100644 index 000000000..bdcffc96e --- /dev/null +++ b/packages/board-portability/src/utils.ts @@ -0,0 +1,52 @@ +import superjson from "superjson"; + +import { emptySuperJSON } from "@homarr/definitions"; + +export const parseStoredValue = (value: string | null | undefined, fallback: T): T => { + if (!value || value === emptySuperJSON) { + return fallback; + } + return superjson.parse(value) as T; +}; + +export const stringifyForDb = (value: unknown): string => { + return superjson.stringify(value); +}; + +export const replaceAppIdsInValue = (value: unknown, appIdToRef: Map): unknown => { + if (typeof value === "string") { + const ref = appIdToRef.get(value); + return ref ?? value; + } + + if (Array.isArray(value)) { + return value.map((entry) => replaceAppIdsInValue(entry, appIdToRef)); + } + + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, replaceAppIdsInValue(entry, appIdToRef)]), + ); + } + + return value; +}; + +export const replaceAppRefsInValue = (value: unknown, refToAppId: Map): unknown => { + if (typeof value === "string") { + const appId = refToAppId.get(value); + return appId ?? value; + } + + 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; +}; diff --git a/packages/board-portability/tsconfig.json b/packages/board-portability/tsconfig.json new file mode 100644 index 000000000..cbe8483d9 --- /dev/null +++ b/packages/board-portability/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 20d5426f0..52a82574e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,6 +26,7 @@ "dependencies": { "@drizzle-team/brocli": "catalog:", "@homarr/auth": "workspace:^0.1.0", + "@homarr/board-portability": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", diff --git a/packages/cli/src/commands/boards-export.ts b/packages/cli/src/commands/boards-export.ts new file mode 100644 index 000000000..027468e5e --- /dev/null +++ b/packages/cli/src/commands/boards-export.ts @@ -0,0 +1,40 @@ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { command, string } from "@drizzle-team/brocli"; + +import { exportBoardBundleAsync, bundleFilenameForBoard } from "@homarr/board-portability"; +import { db } from "@homarr/db"; +import { boards } from "@homarr/db/schema"; +import { eq } from "@homarr/db"; + +export const boardsExport = command({ + name: "export", + desc: "Export a board as a portable JSON bundle", + options: { + name: string("name").desc("Name of the board to export").required(), + output: string("output").alias("o").desc("Output file path (defaults to homarr-board-.json)"), + }, + // eslint-disable-next-line no-restricted-syntax + handler: async (opts) => { + const board = await db.query.boards.findFirst({ + where: eq(boards.name, opts.name), + }); + + if (!board) { + console.error(`Board "${opts.name}" not found`); + process.exit(1); + } + + const result = await exportBoardBundleAsync(db, board.id, "cli"); + + if (!result) { + console.error(`Failed to export board "${opts.name}"`); + process.exit(1); + } + + const outputPath = resolve(opts.output ?? bundleFilenameForBoard(board.name)); + writeFileSync(outputPath, result.content, "utf-8"); + console.log(`Board exported to ${outputPath}`); + }, +}); diff --git a/packages/cli/src/commands/boards-list.ts b/packages/cli/src/commands/boards-list.ts new file mode 100644 index 000000000..b004b5f6b --- /dev/null +++ b/packages/cli/src/commands/boards-list.ts @@ -0,0 +1,21 @@ +import { command } from "@drizzle-team/brocli"; + +import { db } from "@homarr/db"; + +export const boardsList = command({ + name: "list", + desc: "List all boards (id, name, public)", + // eslint-disable-next-line no-restricted-syntax + handler: async () => { + const all = await db.query.boards.findMany(); + if (all.length === 0) { + console.log("No boards found"); + return; + } + + console.log("ID\t\t\t\tName\t\tPublic"); + for (const board of all) { + console.log(`${board.id}\t${board.name}\t\t${board.isPublic ? "yes" : "no"}`); + } + }, +}); diff --git a/packages/cli/src/commands/boards.ts b/packages/cli/src/commands/boards.ts new file mode 100644 index 000000000..1462af2db --- /dev/null +++ b/packages/cli/src/commands/boards.ts @@ -0,0 +1,10 @@ +import { command } from "@drizzle-team/brocli"; + +import { boardsExport } from "./boards-export"; +import { boardsList } from "./boards-list"; + +export const boardsRoot = command({ + name: "boards", + desc: "Group of commands to manage boards", + subcommands: [boardsList, boardsExport], +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f1b0c658d..36d3b1eea 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,12 +1,13 @@ import { run } from "@drizzle-team/brocli"; +import { boardsRoot } from "./commands/boards"; import { fixUsernames } from "./commands/fix-usernames"; import { integrationsRoot } from "./commands/integrations"; import { recreateAdmin } from "./commands/recreate-admin"; import { resetPassword } from "./commands/reset-password"; import { usersRoot } from "./commands/users"; -const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin]; +const commands = [usersRoot, integrationsRoot, boardsRoot, resetPassword, fixUsernames, recreateAdmin]; void run(commands, { name: "homarr-cli", diff --git a/packages/cron-jobs/package.json b/packages/cron-jobs/package.json index 9d5abe1e3..b0ebd1469 100644 --- a/packages/cron-jobs/package.json +++ b/packages/cron-jobs/package.json @@ -30,6 +30,7 @@ "@homarr/cron-jobs-core": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", + "@homarr/docker": "workspace:^0.1.0", "@homarr/icons": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", "@homarr/ping": "workspace:^0.1.0", diff --git a/packages/db/migrations/mysql/0039_add_docker_app_source.sql b/packages/db/migrations/mysql/0039_add_docker_app_source.sql new file mode 100644 index 000000000..63ad7f1d2 --- /dev/null +++ b/packages/db/migrations/mysql/0039_add_docker_app_source.sql @@ -0,0 +1,17 @@ +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/mysql/meta/0039_snapshot.json b/packages/db/migrations/mysql/meta/0039_snapshot.json new file mode 100644 index 000000000..b0a0e4533 --- /dev/null +++ b/packages/db/migrations/mysql/meta/0039_snapshot.json @@ -0,0 +1,2500 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "baf8e320-bc05-40ba-8ea3-66506ada0f9f", + "prevId": "ddg-bangs-mysql-0038", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "apiKey_id": { + "name": "apiKey_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_id": { + "name": "app_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": [ + "board_id", + "group_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": [ + "board_id", + "user_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('fixed')" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('no-repeat')" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('cover')" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fa5252')" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fd7e14')" + }, + "opacity": { + "name": "opacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('lg')" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "board_id": { + "name": "board_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cron_job_configuration_name": { + "name": "cron_job_configuration_name", + "columns": [ + "name" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "docker_app_source": { + "name": "docker_app_source", + "columns": { + "host": { + "name": "host", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "container_id": { + "name": "container_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "docker_app_source__external_id_idx": { + "name": "docker_app_source__external_id_idx", + "columns": [ + "host", + "external_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "docker_app_source_app_id_app_id_fk": { + "name": "docker_app_source_app_id_app_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "docker_app_source_board_id_board_id_fk": { + "name": "docker_app_source_board_id_board_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "docker_app_source_item_id_item_id_fk": { + "name": "docker_app_source_item_id_item_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "docker_app_source_integration_id_integration_id_fk": { + "name": "docker_app_source_integration_id_integration_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "docker_app_source_host_container_id_pk": { + "name": "docker_app_source_host_container_id_pk", + "columns": [ + "host", + "container_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": [ + "group_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": [ + "home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": [ + "mobile_home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_id": { + "name": "group_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "iconRepository_id": { + "name": "iconRepository_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": [ + "icon_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "icon_id": { + "name": "icon_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": [ + "integration_id", + "group_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": [ + "item_id", + "integration_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": [ + "kind" + ], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": [ + "integration_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": [ + "integration_id", + "user_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": [ + "kind" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_id": { + "name": "integration_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "invite_id": { + "name": "invite_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": [ + "token" + ] + } + }, + "checkConstraint": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": [ + "layout_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": [ + "item_id", + "section_id", + "layout_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_id": { + "name": "item_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "layout_id": { + "name": "layout_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "LONGBLOB", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_id": { + "name": "media_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onboarding_id": { + "name": "onboarding_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "search_engine_id": { + "name": "search_engine_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": [ + "short" + ] + } + }, + "checkConstraint": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": [ + "user_id", + "section_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": [ + "layout_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": [ + "parent_section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": [ + "section_id", + "layout_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_id": { + "name": "section_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "serverSetting_setting_key": { + "name": "serverSetting_setting_key", + "columns": [ + "setting_key" + ] + } + }, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": [ + "setting_key" + ] + } + }, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_session_token": { + "name": "session_session_token", + "columns": [ + "session_token" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": [ + "home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": [ + "mobile_home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": [ + "default_search_engine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": [ + "hostname", + "thumbprint" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index 421c81699..c30d39f65 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1775400000000, "tag": "0038_add_user_ddg_bangs", "breakpoints": true + }, + { + "idx": 39, + "version": "5", + "when": 1780490691902, + "tag": "0039_add_docker_app_source", + "breakpoints": true } ] -} +} \ 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 new file mode 100644 index 000000000..e2bb201df --- /dev/null +++ b/packages/db/migrations/postgresql/0005_add_docker_app_source.sql @@ -0,0 +1,17 @@ +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/postgresql/meta/0005_snapshot.json b/packages/db/migrations/postgresql/meta/0005_snapshot.json new file mode 100644 index 000000000..6229b3707 --- /dev/null +++ b/packages/db/migrations/postgresql/meta/0005_snapshot.json @@ -0,0 +1,2360 @@ +{ + "id": "ac193a04-5dd9-48ec-9d85-654e6610fab8", + "prevId": "ddg-bangs-pg-0004", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apiKey": { + "name": "apiKey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardGroupPermission": { + "name": "boardGroupPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": [ + "board_id", + "group_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardUserPermission": { + "name": "boardUserPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": [ + "board_id", + "user_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board": { + "name": "board", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cron_job_configuration": { + "name": "cron_job_configuration", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docker_app_source": { + "name": "docker_app_source", + "schema": "", + "columns": { + "host": { + "name": "host", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "docker_app_source__external_id_idx": { + "name": "docker_app_source__external_id_idx", + "columns": [ + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "docker_app_source_app_id_app_id_fk": { + "name": "docker_app_source_app_id_app_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "docker_app_source_board_id_board_id_fk": { + "name": "docker_app_source_board_id_board_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "docker_app_source_item_id_item_id_fk": { + "name": "docker_app_source_item_id_item_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "docker_app_source_integration_id_integration_id_fk": { + "name": "docker_app_source_integration_id_integration_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "docker_app_source_host_container_id_pk": { + "name": "docker_app_source_host_container_id_pk", + "columns": [ + "host", + "container_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupMember": { + "name": "groupMember", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": [ + "group_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupPermission": { + "name": "groupPermission", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group": { + "name": "group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": [ + "home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": [ + "mobile_home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.iconRepository": { + "name": "iconRepository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.icon": { + "name": "icon", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": [ + "icon_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": [ + "integration_id", + "group_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_item": { + "name": "integration_item", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": [ + "item_id", + "integration_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationSecret": { + "name": "integrationSecret", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": [ + "integration_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationUserPermission": { + "name": "integrationUserPermission", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": [ + "integration_id", + "user_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_layout": { + "name": "item_layout", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": [ + "layout_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": [ + "item_id", + "section_id", + "layout_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item": { + "name": "item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.layout": { + "name": "layout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "column_count": { + "name": "column_count", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding": { + "name": "onboarding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_engine": { + "name": "search_engine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "nullsNotDistinct": false, + "columns": [ + "short" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_collapse_state": { + "name": "section_collapse_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": [ + "user_id", + "section_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_layout": { + "name": "section_layout", + "schema": "", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": [ + "layout_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": [ + "parent_section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": [ + "section_id", + "layout_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section": { + "name": "section", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverSetting": { + "name": "serverSetting", + "schema": "", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "nullsNotDistinct": false, + "columns": [ + "setting_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": [ + "home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": [ + "mobile_home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": [ + "default_search_engine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "schema": "", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": [ + "hostname", + "thumbprint" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json index d0a3f7683..c695a1120 100644 --- a/packages/db/migrations/postgresql/meta/_journal.json +++ b/packages/db/migrations/postgresql/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1775400000000, "tag": "0004_add_user_ddg_bangs", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1780490693344, + "tag": "0005_add_docker_app_source", + "breakpoints": true } ] -} +} \ 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 new file mode 100644 index 000000000..7c3e8cc09 --- /dev/null +++ b/packages/db/migrations/sqlite/0037_add_docker_app_source.sql @@ -0,0 +1,17 @@ +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/db/migrations/sqlite/meta/0037_snapshot.json b/packages/db/migrations/sqlite/meta/0037_snapshot.json new file mode 100644 index 000000000..061c8afc4 --- /dev/null +++ b/packages/db/migrations/sqlite/meta/0037_snapshot.json @@ -0,0 +1,2379 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fd156df1-3378-4d8b-924a-2bb548f8b3a8", + "prevId": "ddg-bangs-sqlite-0036", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": [ + "provider", + "provider_account_id" + ], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "columns": [ + "board_id", + "group_id", + "permission" + ], + "name": "boardGroupPermission_board_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "columns": [ + "board_id", + "user_id", + "permission" + ], + "name": "boardUserPermission_board_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "board_name_unique": { + "name": "board_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "docker_app_source": { + "name": "docker_app_source", + "columns": { + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "docker_app_source__external_id_idx": { + "name": "docker_app_source__external_id_idx", + "columns": [ + "host", + "external_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "docker_app_source_app_id_app_id_fk": { + "name": "docker_app_source_app_id_app_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "docker_app_source_board_id_board_id_fk": { + "name": "docker_app_source_board_id_board_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "docker_app_source_item_id_item_id_fk": { + "name": "docker_app_source_item_id_item_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "docker_app_source_integration_id_integration_id_fk": { + "name": "docker_app_source_integration_id_integration_id_fk", + "tableFrom": "docker_app_source", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "docker_app_source_host_container_id_pk": { + "columns": [ + "host", + "container_id" + ], + "name": "docker_app_source_host_container_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "columns": [ + "group_id", + "user_id" + ], + "name": "groupMember_group_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "group_name_unique": { + "name": "group_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": [ + "home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": [ + "mobile_home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": [ + "icon_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationGroupPermissions_integration_id_group_id_permission_pk": { + "columns": [ + "integration_id", + "group_id", + "permission" + ], + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "columns": [ + "item_id", + "integration_id" + ], + "name": "integration_item_item_id_integration_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": [ + "kind" + ], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "columns": [ + "integration_id", + "kind" + ], + "name": "integrationSecret_integration_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "columns": [ + "integration_id", + "user_id", + "permission" + ], + "name": "integrationUserPermission_integration_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": [ + "kind" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": [ + "layout_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "columns": [ + "item_id", + "section_id", + "layout_id" + ], + "name": "item_layout_item_id_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": [ + "short" + ], + "isUnique": true + } + }, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "columns": [ + "user_id", + "section_id" + ], + "name": "section_collapse_state_user_id_section_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": [ + "layout_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": [ + "parent_section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "columns": [ + "section_id", + "layout_id" + ], + "name": "section_layout_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": [ + "setting_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "columns": [ + "hostname", + "thumbprint" + ], + "name": "trusted_certificate_hostname_hostname_thumbprint_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": [ + "home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": [ + "mobile_home_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": [ + "default_search_engine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index 5543bbde0..1004b3ac4 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1775400000000, "tag": "0036_add_user_ddg_bangs", "breakpoints": true + }, + { + "idx": 37, + "version": "6", + "when": 1780490686287, + "tag": "0037_add_docker_app_source", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index 9929ccb86..69b8fddbb 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -49,6 +49,7 @@ export const { sectionLayouts, trustedCertificateHostnames, cronJobConfigurations, + dockerAppSources, } = schema; export type User = InferSelectModel; diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 84c5325ed..832507ea4 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -502,6 +502,33 @@ export const cronJobConfigurations = mysqlTable("cron_job_configuration", { isEnabled: boolean().default(true).notNull(), }); +export const dockerAppSources = mysqlTable( + "docker_app_source", + { + host: varchar({ length: 512 }).notNull(), + containerId: varchar({ length: 128 }).notNull(), + externalId: varchar({ length: 512 }).notNull(), + appId: varchar({ length: 64 }) + .notNull() + .references(() => apps.id, { onDelete: "cascade" }), + boardId: varchar({ length: 64 }) + .notNull() + .references(() => boards.id, { onDelete: "cascade" }), + itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }), + integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }), + updatedAt: timestamp() + .notNull() + .$onUpdateFn(() => new Date()) + .$defaultFn(() => new Date()), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.host, table.containerId], + }), + externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId), + }), +); + export const accountRelations = relations(accounts, ({ one }) => ({ user: one(users, { fields: [accounts.userId], @@ -766,3 +793,22 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({ references: [boards.id], }), })); + +export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({ + app: one(apps, { + fields: [dockerAppSources.appId], + references: [apps.id], + }), + board: one(boards, { + fields: [dockerAppSources.boardId], + references: [boards.id], + }), + item: one(items, { + fields: [dockerAppSources.itemId], + references: [items.id], + }), + integration: one(integrations, { + fields: [dockerAppSources.integrationId], + references: [integrations.id], + }), +})); diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts index 412916013..759af4f06 100644 --- a/packages/db/schema/postgresql.ts +++ b/packages/db/schema/postgresql.ts @@ -501,6 +501,33 @@ export const cronJobConfigurations = pgTable("cron_job_configuration", { isEnabled: boolean().default(true).notNull(), }); +export const dockerAppSources = pgTable( + "docker_app_source", + { + host: varchar({ length: 512 }).notNull(), + containerId: varchar({ length: 128 }).notNull(), + externalId: varchar({ length: 512 }).notNull(), + appId: varchar({ length: 64 }) + .notNull() + .references(() => apps.id, { onDelete: "cascade" }), + boardId: varchar({ length: 64 }) + .notNull() + .references(() => boards.id, { onDelete: "cascade" }), + itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }), + integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }), + updatedAt: timestamp() + .notNull() + .$onUpdateFn(() => new Date()) + .$defaultFn(() => new Date()), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.host, table.containerId], + }), + externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId), + }), +); + export const accountRelations = relations(accounts, ({ one }) => ({ user: one(users, { fields: [accounts.userId], @@ -765,3 +792,22 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({ references: [boards.id], }), })); + +export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({ + app: one(apps, { + fields: [dockerAppSources.appId], + references: [apps.id], + }), + board: one(boards, { + fields: [dockerAppSources.boardId], + references: [boards.id], + }), + item: one(items, { + fields: [dockerAppSources.itemId], + references: [items.id], + }), + integration: one(integrations, { + fields: [dockerAppSources.integrationId], + references: [integrations.id], + }), +})); diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 50671db85..adc1aed2c 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -487,6 +487,33 @@ export const cronJobConfigurations = sqliteTable("cron_job_configuration", { isEnabled: int({ mode: "boolean" }).default(true).notNull(), }); +export const dockerAppSources = sqliteTable( + "docker_app_source", + { + host: text().notNull(), + containerId: text().notNull(), + externalId: text().notNull(), + appId: text() + .notNull() + .references(() => apps.id, { onDelete: "cascade" }), + boardId: text() + .notNull() + .references(() => boards.id, { onDelete: "cascade" }), + itemId: text().references(() => items.id, { onDelete: "set null" }), + integrationId: text().references(() => integrations.id, { onDelete: "set null" }), + updatedAt: int({ mode: "timestamp" }) + .$onUpdateFn(() => new Date()) + .notNull() + .$defaultFn(() => new Date()), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.host, table.containerId], + }), + externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId), + }), +); + export const accountRelations = relations(accounts, ({ one }) => ({ user: one(users, { fields: [accounts.userId], @@ -751,3 +778,22 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({ references: [boards.id], }), })); + +export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({ + app: one(apps, { + fields: [dockerAppSources.appId], + references: [apps.id], + }), + board: one(boards, { + fields: [dockerAppSources.boardId], + references: [boards.id], + }), + item: one(items, { + fields: [dockerAppSources.itemId], + references: [items.id], + }), + integration: one(integrations, { + fields: [dockerAppSources.integrationId], + references: [integrations.id], + }), +})); diff --git a/packages/docker/package.json b/packages/docker/package.json index a3d5816fd..4b00287bb 100644 --- a/packages/docker/package.json +++ b/packages/docker/package.json @@ -25,11 +25,15 @@ "dependencies": { "@homarr/common": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0", - "dockerode": "catalog:" + "@homarr/db": "workspace:^0.1.0", + "@homarr/definitions": "workspace:^0.1.0", + "dockerode": "catalog:", + "superjson": "catalog:" }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", "@types/dockerode": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/docker/src/discovery/__tests__/parse-container-labels.spec.ts b/packages/docker/src/discovery/__tests__/parse-container-labels.spec.ts new file mode 100644 index 000000000..458e9345a --- /dev/null +++ b/packages/docker/src/discovery/__tests__/parse-container-labels.spec.ts @@ -0,0 +1,137 @@ +import type { ContainerInfo } from "dockerode"; +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", + 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", + ); + + 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("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", + ); + + 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("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( + createContainer({ + [dockerLabels.name]: "Incomplete", + [dockerLabels.href]: "https://incomplete.local", + }), + "socket", + ), + ).toBeNull(); + + expect( + parseContainerLabels( + createContainer({ + [homepageLabels.name]: "No group", + [homepageLabels.href]: "https://nogroup.local", + }), + "socket", + ), + ).toBeNull(); + }); + + 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", + ); + + expect(result?.group).toBe("Homarr Group"); + expect(result?.name).toBe("Homarr Name"); + expect(result?.href).toBe("https://homarr.local"); + }); + + 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(); + }); +}); diff --git a/packages/docker/src/discovery/index.ts b/packages/docker/src/discovery/index.ts new file mode 100644 index 000000000..8ee3b0a6f --- /dev/null +++ b/packages/docker/src/discovery/index.ts @@ -0,0 +1,8 @@ +export type { DiscoveredService } from "./types"; +export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels"; +export { listDiscoveredContainersAsync } from "./list-discovered-containers"; +export { + syncDiscoveredServicesAsync, + type SyncDiscoveredServicesOptions, + type SyncDiscoveredServicesResult, +} from "./sync-discovered-services"; diff --git a/packages/docker/src/discovery/list-discovered-containers.ts b/packages/docker/src/discovery/list-discovered-containers.ts new file mode 100644 index 000000000..74e15a5a0 --- /dev/null +++ b/packages/docker/src/discovery/list-discovered-containers.ts @@ -0,0 +1,45 @@ +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; + +import { dockerLabels, homepageLabels } from "../labels"; +import { DockerSingleton } from "../singleton"; +import type { ParseContainerLabelsOptions } from "./parse-container-labels"; +import { parseContainerLabels } from "./parse-container-labels"; +import type { DiscoveredService } from "./types"; + +const logger = createLogger({ module: "dockerDiscovery" }); + +const hasDiscoveryLabels = (labels: Record) => + Object.values(dockerLabels).some((label) => label !== dockerLabels.hide && label in labels) || + Object.values(homepageLabels).some((label) => label in labels); + +export const listDiscoveredContainersAsync = async ( + options: ParseContainerLabelsOptions = {}, +): Promise => { + const dockerInstances = DockerSingleton.getInstances(); + const results = await Promise.allSettled( + dockerInstances.map(async ({ instance, host }) => { + const containers = await instance.listContainers({ all: true }); + return containers + .filter((container) => hasDiscoveryLabels(container.Labels ?? {})) + .map((container) => parseContainerLabels(container, host, options)) + .filter((service): service is DiscoveredService => service !== null); + }), + ); + + return results.flatMap((result, index) => { + if (result.status === "fulfilled") { + return result.value; + } + + logger.warn( + new ErrorWithMetadata( + "Failed to list containers for discovery", + { host: dockerInstances[index]?.host }, + { cause: result.reason }, + ), + ); + + return []; + }); +}; diff --git a/packages/docker/src/discovery/parse-container-labels.ts b/packages/docker/src/discovery/parse-container-labels.ts new file mode 100644 index 000000000..f3564850c --- /dev/null +++ b/packages/docker/src/discovery/parse-container-labels.ts @@ -0,0 +1,110 @@ +import type { ContainerInfo } from "dockerode"; + +import { integrationKinds, widgetKinds } from "@homarr/definitions"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; + +import { dockerLabels, homepageLabels } from "../labels"; +import type { DiscoveredService } from "./types"; + +export interface ParseContainerLabelsOptions { + readHomepageLabels?: boolean; +} + +const homarrLabelKeys = { + group: dockerLabels.group, + name: dockerLabels.name, + href: dockerLabels.href, + icon: dockerLabels.icon, + description: dockerLabels.description, + ping: dockerLabels.ping, + id: dockerLabels.id, + board: dockerLabels.board, + integration: dockerLabels.integration, + widget: dockerLabels.widget, +} as const; + +const homepageLabelKeys = { + group: homepageLabels.group, + name: homepageLabels.name, + href: homepageLabels.href, + icon: homepageLabels.icon, + description: homepageLabels.description, +} as const; + +const integrationKindSet = new Set(integrationKinds); +const widgetKindSet = new Set(widgetKinds); + +const readLabel = (labels: Record, key: string) => labels[key]?.trim(); + +const resolveLabelValue = ( + labels: Record, + homarrKey: string, + homepageKey: string, + useHomepageFallback: boolean, +) => readLabel(labels, homarrKey) ?? (useHomepageFallback ? readLabel(labels, homepageKey) : undefined); + +export const parseContainerLabels = ( + container: Pick, + host: string, + options: ParseContainerLabelsOptions = {}, +): DiscoveredService | null => { + const labels = container.Labels ?? {}; + + if (dockerLabels.hide in labels) { + return null; + } + + const readHomepageLabels = options.readHomepageLabels ?? true; + const hasHomarrName = Boolean(readLabel(labels, dockerLabels.name)); + const useHomepageFallback = readHomepageLabels && !hasHomarrName; + + const name = resolveLabelValue(labels, homarrLabelKeys.name, homepageLabelKeys.name, useHomepageFallback); + const href = resolveLabelValue(labels, homarrLabelKeys.href, homepageLabelKeys.href, useHomepageFallback); + const group = resolveLabelValue(labels, homarrLabelKeys.group, homepageLabelKeys.group, useHomepageFallback); + + if (!name || !href || !group) { + return null; + } + + const icon = + readLabel(labels, homarrLabelKeys.icon) ?? + (useHomepageFallback ? readLabel(labels, homepageLabelKeys.icon) : undefined); + const description = + readLabel(labels, homarrLabelKeys.description) ?? + (useHomepageFallback ? readLabel(labels, homepageLabelKeys.description) : undefined); + const pingUrl = readLabel(labels, homarrLabelKeys.ping); + const boardName = readLabel(labels, homarrLabelKeys.board); + const externalId = readLabel(labels, homarrLabelKeys.id) ?? container.Id; + + const integrationKind = parseIntegrationKind(readLabel(labels, homarrLabelKeys.integration)); + const widgetKind = parseWidgetKind(readLabel(labels, homarrLabelKeys.widget)); + + return { + containerId: container.Id, + host, + group, + name, + href, + icon, + description, + pingUrl, + externalId, + boardName, + integrationKind, + widgetKind, + }; +}; + +const parseIntegrationKind = (value: string | undefined): IntegrationKind | undefined => { + if (!value || !integrationKindSet.has(value)) { + return undefined; + } + return value as IntegrationKind; +}; + +const parseWidgetKind = (value: string | undefined): WidgetKind | undefined => { + if (!value || !widgetKindSet.has(value)) { + return undefined; + } + return value as WidgetKind; +}; diff --git a/packages/docker/src/discovery/sync-discovered-services.ts b/packages/docker/src/discovery/sync-discovered-services.ts new file mode 100644 index 000000000..09f5e0979 --- /dev/null +++ b/packages/docker/src/discovery/sync-discovered-services.ts @@ -0,0 +1,451 @@ +import SuperJSON from "superjson"; + +import { createId } from "@homarr/common"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { emptySuperJSON } from "@homarr/definitions"; +import type { InferInsertModel, InferSelectModel } from "@homarr/db"; +import { and, asc, db, eq, handleTransactionsAsync } from "@homarr/db"; +import { createDbInsertCollectionForTransaction } from "@homarr/db/collection"; +import { apps, boards, dockerAppSources, itemLayouts, items, layouts, sections } from "@homarr/db/schema"; + +import type { DiscoveredService } from "./types"; + +const logger = createLogger({ module: "dockerDiscoverySync" }); + +export interface SyncDiscoveredServicesOptions { + targetBoardName?: string | null; + enableStatusByDefault?: boolean; + forceDisableStatus?: boolean; + defaultItemWidth?: number; + defaultItemHeight?: number; +} + +export interface SyncDiscoveredServicesResult { + created: number; + updated: number; + skipped: number; +} + +interface SyncMetadata { + appId: string; + itemId?: string; +} + +interface AppItemOptions { + appId: string; + openInNewTab: boolean; + showTitle: boolean; + descriptionDisplayMode: "hidden"; + layout: "column"; + pingEnabled: boolean; +} + +type AppRow = InferSelectModel; + +type BoardWithRelations = NonNullable>>[number]; + +type BoardItem = BoardWithRelations["items"][number]; + +const createSyncKey = (service: DiscoveredService) => `${service.host}/${service.containerId}`; + +const appsMatchByHref = (app: Pick, href: string) => app.href === href; + +export const syncDiscoveredServicesAsync = async ( + discoveredServices: DiscoveredService[], + options: SyncDiscoveredServicesOptions = {}, +): Promise => { + const itemWidth = options.defaultItemWidth ?? 1; + const itemHeight = options.defaultItemHeight ?? 1; + const result: SyncDiscoveredServicesResult = { created: 0, updated: 0, skipped: 0 }; + const syncMetadata = new Map(); + const existingApps = await db.query.apps.findMany(); + const existingSources = await db.query.dockerAppSources.findMany(); + const boardList = await loadBoardsWithRelationsAsync(); + + const appsToInsert: InferInsertModel[] = []; + const appsToUpdate: { id: string; values: Partial> }[] = []; + const sectionsToInsert: InferInsertModel[] = []; + const itemsToInsert: InferInsertModel[] = []; + const itemLayoutsToInsert: InferInsertModel[] = []; + + const pendingSectionsByBoard = new Map[]>(); + const sectionYOffsetByBoard = new Map(); + const itemYOffsetBySection = new Map(); + const pendingItemLayouts: InferInsertModel[] = []; + + for (const service of discoveredServices) { + const syncKey = createSyncKey(service); + + if (syncMetadata.has(syncKey)) { + result.skipped += 1; + continue; + } + + const board = resolveTargetBoard(boardList, service, options.targetBoardName); + if (!board) { + logger.warn("No target board found for discovered service", { syncKey, boardName: service.boardName }); + result.skipped += 1; + continue; + } + + const firstLayout = getFirstLayout(board.layouts); + if (!firstLayout) { + logger.warn("Board has no layouts for discovered service", { boardId: board.id, syncKey }); + result.skipped += 1; + continue; + } + + const existingSource = existingSources.find( + (source) => source.host === service.host && source.externalId === service.externalId, + ); + const existingApp = existingSource + ? existingApps.find((app) => app.id === existingSource.appId) + : existingApps.find((app) => appsMatchByHref(app, service.href)); + let appId: string; + let wasCreated = false; + let wasUpdated = false; + + if (existingApp) { + appId = existingApp.id; + const updateValues = buildAppUpdateValues(service, existingApp); + if (updateValues) { + appsToUpdate.push({ id: appId, values: updateValues }); + wasUpdated = true; + Object.assign(existingApp, updateValues); + } + } else { + appId = createId(); + const newApp = buildAppInsert(service, appId); + appsToInsert.push(newApp); + existingApps.push(newApp as AppRow); + wasCreated = true; + } + + const categorySection = findOrCreateCategorySection( + board, + service.group, + sectionsToInsert, + pendingSectionsByBoard, + sectionYOffsetByBoard, + ); + + const existingItem = findAppItemForApp(board.items, itemsToInsert, pendingItemLayouts, appId, categorySection.id); + + if (!existingItem) { + const newItemId = createId(); + const yOffset = getNextItemYOffset( + board, + categorySection.id, + firstLayout.id, + itemYOffsetBySection, + pendingItemLayouts, + itemHeight, + ); + + const newItemLayouts = board.layouts.map((layout) => ({ + itemId: newItemId, + sectionId: categorySection.id, + layoutId: layout.id, + xOffset: 0, + yOffset, + width: itemWidth, + height: itemHeight, + })); + + itemsToInsert.push({ + id: newItemId, + boardId: board.id, + kind: "app", + options: SuperJSON.stringify(buildAppItemOptions(appId, service, options)), + advancedOptions: SuperJSON.stringify({ + title: null, + customCssClasses: [], + borderColor: "", + }), + }); + + itemLayoutsToInsert.push(...newItemLayouts); + pendingItemLayouts.push(...newItemLayouts); + syncMetadata.set(syncKey, { appId, itemId: newItemId }); + wasCreated = true; + } else { + syncMetadata.set(syncKey, { appId, itemId: existingItem.id }); + } + + if (wasCreated) { + result.created += 1; + } else if (wasUpdated) { + result.updated += 1; + } else { + result.skipped += 1; + } + } + + for (const { id, values } of appsToUpdate) { + await db.update(apps).set(values).where(eq(apps.id, id)); + } + + const sourcesToUpsert: InferInsertModel[] = []; + for (const [syncKey, meta] of syncMetadata.entries()) { + const service = discoveredServices.find((svc) => createSyncKey(svc) === syncKey); + if (!service) continue; + + const board = resolveTargetBoard(boardList, service, options.targetBoardName); + if (!board) continue; + + sourcesToUpsert.push({ + host: service.host, + containerId: service.containerId, + externalId: service.externalId, + appId: meta.appId, + boardId: board.id, + itemId: meta.itemId ?? null, + }); + } + + if ( + appsToInsert.length === 0 && + sectionsToInsert.length === 0 && + itemsToInsert.length === 0 && + sourcesToUpsert.length === 0 + ) { + return result; + } + + const insertCollection = createDbInsertCollectionForTransaction(["apps", "sections", "items", "itemLayouts"]); + insertCollection.apps.push(...appsToInsert); + insertCollection.sections.push(...sectionsToInsert); + insertCollection.items.push(...itemsToInsert); + insertCollection.itemLayouts.push(...itemLayoutsToInsert); + + await handleTransactionsAsync(db, { + async handleAsync(innerDb) { + await insertCollection.insertAllAsync(innerDb); + const table = dockerAppSources as never; + for (const source of sourcesToUpsert) { + const existing = await innerDb.query.dockerAppSources.findFirst({ + where: and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)), + }); + if (existing) { + await innerDb + .update(table) + .set({ appId: source.appId, boardId: source.boardId, itemId: source.itemId, externalId: source.externalId }) + .where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId))); + } else { + await innerDb.insert(table).values(source as never); + } + } + }, + handleSync(innerDb) { + insertCollection.insertAll(innerDb); + for (const source of sourcesToUpsert) { + try { + innerDb.insert(dockerAppSources).values(source).run(); + } catch { + innerDb + .update(dockerAppSources) + .set({ appId: source.appId, boardId: source.boardId, itemId: source.itemId, externalId: source.externalId }) + .where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId))) + .run(); + } + } + }, + }); + + return result; +}; + +const loadBoardsWithRelationsAsync = async () => + db.query.boards.findMany({ + orderBy: asc(boards.name), + with: { + layouts: true, + sections: true, + items: { + with: { + layouts: true, + }, + }, + }, + }); + +const resolveTargetBoard = ( + boardList: BoardWithRelations[], + service: DiscoveredService, + defaultBoardName: string | null | undefined, +) => { + const boardName = service.boardName ?? defaultBoardName ?? undefined; + if (boardName) { + return boardList.find((board) => board.name === boardName) ?? boardList.at(0); + } + return boardList.at(0); +}; + +const getFirstLayout = (boardLayouts: InferSelectModel[]) => + [...boardLayouts].sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint).at(0); + +const buildAppInsert = (service: DiscoveredService, appId: string): InferInsertModel => ({ + id: appId, + name: service.name, + description: service.description ?? null, + iconUrl: service.icon ?? "", + href: service.href, + pingUrl: service.pingUrl ?? null, +}); + +const buildAppUpdateValues = ( + service: DiscoveredService, + existingApp: AppRow, +): Partial> | null => { + const values: Partial> = {}; + + if (existingApp.name !== service.name) { + values.name = service.name; + } + if (existingApp.href !== service.href) { + values.href = service.href; + } + if (service.icon !== undefined && existingApp.iconUrl !== service.icon) { + values.iconUrl = service.icon; + } + if (service.description !== undefined && existingApp.description !== service.description) { + values.description = service.description; + } + if (service.pingUrl !== undefined && existingApp.pingUrl !== service.pingUrl) { + values.pingUrl = service.pingUrl; + } + + return Object.keys(values).length > 0 ? values : null; +}; + +const buildAppItemOptions = ( + appId: string, + service: DiscoveredService, + options: SyncDiscoveredServicesOptions, +): AppItemOptions => ({ + appId, + openInNewTab: true, + showTitle: true, + descriptionDisplayMode: "hidden", + layout: "column", + pingEnabled: service.pingUrl + ? true + : !(options.forceDisableStatus ?? false) && (options.enableStatusByDefault ?? true), +}); + +const findOrCreateCategorySection = ( + board: BoardWithRelations, + groupName: string, + sectionsToInsert: InferInsertModel[], + pendingSectionsByBoard: Map[]>, + sectionYOffsetByBoard: Map, +): InferInsertModel & { id: string } => { + const pendingForBoard = pendingSectionsByBoard.get(board.id) ?? []; + const pendingMatch = pendingForBoard.find((section) => section.kind === "category" && section.name === groupName); + if (pendingMatch) { + return pendingMatch as InferInsertModel & { id: string }; + } + + const existingSection = board.sections.find((section) => section.kind === "category" && section.name === groupName); + if (existingSection) { + return existingSection; + } + + const categorySections = board.sections.filter((section) => section.kind === "category"); + const pendingCategorySections = pendingForBoard.filter((section) => section.kind === "category"); + const maxYOffset = Math.max( + 0, + ...categorySections.map((section) => section.yOffset ?? 0), + ...pendingCategorySections.map((section) => section.yOffset ?? 0), + sectionYOffsetByBoard.get(board.id) ?? 0, + ); + const yOffset = maxYOffset + 1; + sectionYOffsetByBoard.set(board.id, yOffset); + + const newSection: InferInsertModel = { + id: createId(), + boardId: board.id, + kind: "category", + xOffset: 0, + yOffset, + name: groupName, + options: emptySuperJSON, + }; + + sectionsToInsert.push(newSection); + board.sections.push(newSection as BoardWithRelations["sections"][number]); + pendingSectionsByBoard.set(board.id, [...pendingForBoard, newSection]); + + return newSection as InferInsertModel & { id: string }; +}; + +const findAppItemForApp = ( + boardItems: BoardItem[], + pendingItems: InferInsertModel[], + pendingLayouts: InferInsertModel[], + appId: string, + sectionId: string, +) => { + const dbMatch = boardItems.find((item) => itemMatchesAppInSection(item, appId, sectionId)); + if (dbMatch) { + return dbMatch; + } + + const pendingMatch = pendingItems.find((item) => { + if (item.kind !== "app") { + return false; + } + const itemOptions = SuperJSON.parse(item.options ?? emptySuperJSON); + if (itemOptions.appId !== appId) { + return false; + } + return pendingLayouts.some((layout) => layout.itemId === item.id && layout.sectionId === sectionId); + }); + + return pendingMatch ? { id: pendingMatch.id } : undefined; +}; + +const itemMatchesAppInSection = (item: BoardItem, appId: string, sectionId: string) => { + if (item.kind !== "app") { + return false; + } + + const itemOptions = SuperJSON.parse(item.options); + if (itemOptions.appId !== appId) { + return false; + } + + return item.layouts.some((layout) => layout.sectionId === sectionId); +}; + +const getNextItemYOffset = ( + board: BoardWithRelations, + sectionId: string, + layoutId: string, + itemYOffsetBySection: Map, + pendingLayouts: InferInsertModel[], + itemHeight: number, +) => { + const sectionKey = `${board.id}/${sectionId}/${layoutId}`; + const trackedYOffset = itemYOffsetBySection.get(sectionKey); + if (trackedYOffset !== undefined) { + const nextYOffset = trackedYOffset + itemHeight; + itemYOffsetBySection.set(sectionKey, nextYOffset); + return trackedYOffset; + } + + const dbLayouts = board.items.flatMap((item) => + item.layouts.filter((layout) => layout.sectionId === sectionId && layout.layoutId === layoutId), + ); + const pendingSectionLayouts = pendingLayouts.filter( + (layout) => layout.sectionId === sectionId && layout.layoutId === layoutId, + ); + + const maxYOffset = [...dbLayouts, ...pendingSectionLayouts].reduce( + (max, layout) => Math.max(max, layout.yOffset + layout.height), + 0, + ); + + itemYOffsetBySection.set(sectionKey, maxYOffset + itemHeight); + return maxYOffset; +}; diff --git a/packages/docker/src/discovery/types.ts b/packages/docker/src/discovery/types.ts new file mode 100644 index 000000000..2556063fe --- /dev/null +++ b/packages/docker/src/discovery/types.ts @@ -0,0 +1,16 @@ +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; + +export interface DiscoveredService { + containerId: string; + host: string; + group: string; + name: string; + href: string; + icon?: string; + description?: string; + pingUrl?: string; + externalId: string; + boardName?: string; + integrationKind?: IntegrationKind; + widgetKind?: WidgetKind; +} diff --git a/packages/docker/src/index.ts b/packages/docker/src/index.ts index bd8f5dc2a..2a2b8012b 100644 --- a/packages/docker/src/index.ts +++ b/packages/docker/src/index.ts @@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e export type ContainerState = (typeof containerStates)[number]; export * from "./labels"; +export * from "./discovery"; diff --git a/packages/docker/src/labels.ts b/packages/docker/src/labels.ts index 84bf0071f..45b60bce5 100644 --- a/packages/docker/src/labels.ts +++ b/packages/docker/src/labels.ts @@ -1,4 +1,21 @@ export const dockerLabels = { - // Label to hide a container from Homarrs docker integration hide: "homarr.hide", + group: "homarr.group", + name: "homarr.name", + href: "homarr.href", + icon: "homarr.icon", + description: "homarr.description", + ping: "homarr.ping", + id: "homarr.id", + board: "homarr.board", + integration: "homarr.integration", + widget: "homarr.widget", +} as const; + +export const homepageLabels = { + group: "homepage.group", + name: "homepage.name", + href: "homepage.href", + icon: "homepage.icon", + description: "homepage.description", } as const; diff --git a/packages/modals-collection/package.json b/packages/modals-collection/package.json index dbf66ffb6..1dd294e73 100644 --- a/packages/modals-collection/package.json +++ b/packages/modals-collection/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@homarr/api": "workspace:^0.1.0", + "@homarr/board-portability": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", "@homarr/forms-collection": "workspace:^0.1.0", diff --git a/packages/modals-collection/src/boards/export-board-modal.tsx b/packages/modals-collection/src/boards/export-board-modal.tsx new file mode 100644 index 000000000..e8cdbf65f --- /dev/null +++ b/packages/modals-collection/src/boards/export-board-modal.tsx @@ -0,0 +1,64 @@ +import { Button, Group, Stack, Text } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { createModal } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; + +interface InnerProps { + board: { + id: string; + name: string; + }; +} + +export const ExportBoardModal = createModal(({ actions, innerProps }) => { + const t = useI18n() as unknown as (key: string) => string; + const { mutateAsync, isPending } = clientApi.board.exportBundle.useMutation(); + + const handleExport = async () => { + await mutateAsync( + { id: innerProps.board.id }, + { + onSuccess({ content, filename }) { + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + actions.closeModal(); + showSuccessNotification({ + title: t("board.action.export.notification.success.title"), + message: t("board.action.export.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("board.action.export.notification.error.title"), + message: t("board.action.export.notification.error.message"), + }); + }, + }, + ); + }; + + return ( + + + {t("board.action.export.label")}: {innerProps.board.name} + + + + + + + ); +}).withOptions({ + defaultTitle: (t) => (t as (key: string) => string)("board.action.export.label"), +}); diff --git a/packages/modals-collection/src/boards/import-bundle-modal.tsx b/packages/modals-collection/src/boards/import-bundle-modal.tsx new file mode 100644 index 000000000..5a3e23070 --- /dev/null +++ b/packages/modals-collection/src/boards/import-bundle-modal.tsx @@ -0,0 +1,139 @@ +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 new file mode 100644 index 000000000..8a4ca1638 --- /dev/null +++ b/packages/modals-collection/src/boards/import-homepage-modal.tsx @@ -0,0 +1,195 @@ +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 8594bb424..ab287b15b 100644 --- a/packages/modals-collection/src/boards/index.ts +++ b/packages/modals-collection/src/boards/index.ts @@ -1,3 +1,6 @@ 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/server-settings/src/index.ts b/packages/server-settings/src/index.ts index 65b0cd354..d2e9a22a3 100644 --- a/packages/server-settings/src/index.ts +++ b/packages/server-settings/src/index.ts @@ -5,6 +5,7 @@ export const defaultServerSettingsKeys = [ "analytics", "crawlingAndIndexing", "board", + "docker", "user", "appearance", "culture", @@ -32,6 +33,14 @@ export const defaultServerSettings = { enableStatusByDefault: true, forceDisableStatus: false, }, + docker: { + targetBoardName: null as string | null, + readHomepageLabels: true, + pruneRemoved: false, + createIntegrations: true, + defaultItemWidth: 1, + defaultItemHeight: 1, + }, user: { enableGravatar: true, }, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a62728845..b9ac91ed9 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -7,12 +7,20 @@ "description": "To get started, please select how you want to set up your Homarr instance.", "action": { "scratch": "Start from scratch", - "importOldmarr": "Import from Homarr before 1.0" + "importOldmarr": "Import from Homarr before 1.0", + "importConfig": "Import Homarr configuration (.json)" } }, "import": { "title": "Import data", "subtitle": "You can import data from an existing Homarr instance.", + "configDropzone": { + "title": "Drag the configuration file here or click to browse", + "description": "Upload a Homarr full configuration export (.json) to restore boards, apps, integrations, and settings" + }, + "configImport": { + "action": "Confirm import and continue" + }, "dropzone": { "title": "Drag the zip file here or click to browse", "description": "The uploaded zip will be processed and you'll be able to select what you want to import" @@ -3008,6 +3016,74 @@ } } }, + "export": { + "label": "Export board", + "notification": { + "success": { + "title": "Board exported", + "message": "The board bundle was downloaded successfully" + }, + "error": { + "title": "Unable to export board", + "message": "The board could not be exported" + } + } + }, + "importHomepage": { + "label": "Import from Homepage", + "notification": { + "success": { + "title": "Homepage import complete", + "message": "Board created from Homepage services" + }, + "error": { + "title": "Import failed", + "message": "Could not import Homepage services" + } + }, + "modal": { + "title": "Import Homepage services.yaml" + }, + "field": { + "file": { + "label": "Select services.yaml file", + "invalidError": "Invalid Homepage services.yaml file" + }, + "boardName": { + "label": "Board name" + }, + "createIntegrations": { + "label": "Create integrations", + "description": "Automatically create integrations for recognized services" + }, + "envVariables": { + "label": "Environment variables", + "description": "Your services.yaml contains template variables. Fill in the values they should resolve to." + } + } + }, + "importBundle": { + "label": "Import board bundle", + "notification": { + "success": { + "title": "Import successful", + "message": "The board bundle was imported successfully" + }, + "error": { + "title": "Import failed", + "message": "The board bundle could not be imported" + } + }, + "form": { + "file": { + "label": "Select JSON bundle", + "invalidError": "Invalid board bundle file" + }, + "name": { + "label": "Board name" + } + } + }, "edit": { "notification": { "success": { @@ -3378,7 +3454,8 @@ "logs": "Logs", "api": "API", "certificates": "Certificates", - "tasks": "Tasks" + "tasks": "Tasks", + "importExport": "Import / Export" } }, "settings": "Settings", @@ -3446,6 +3523,9 @@ "duplicate": { "label": "Duplicate board" }, + "export": { + "label": "Export board" + }, "delete": { "label": "Delete permanently", "confirm": { @@ -3601,6 +3681,99 @@ } } }, + "importExport": { + "title": "Import / Export", + "summary": { + "title": "Contents", + "empty": "Nothing to include", + "boards": "{count} boards", + "apps": "{count} apps", + "integrations": "{count} integrations", + "secrets": "{count} integration secrets", + "widgets": "{count} widgets", + "sections": "{count} sections", + "layouts": "{count} layouts", + "searchEngines": "{count} search engines", + "groups": "{count} groups", + "serverSettings": "{count} server settings" + }, + "preview": { + "exportLoading": "Loading export summary…", + "importLoading": "Analyzing configuration file…", + "importIncompatible": "This file cannot be imported on this Homarr version", + "importMeta": "Exported {exportedAt} from Homarr {homarrVersion} (format {bundleVersion})", + "importWillCreate": "Will create", + "importWillReuse": "Will match existing", + "importWillSkip": "Will skip (already exists)", + "importWillUpdate": "Will update", + "reuseApps": "{count} apps", + "reuseIntegrations": "{count} integrations", + "skipBoards": "{count} boards", + "skipGroups": "{count} groups", + "skipSearchEngines": "{count} search engines", + "updateServerSettings": "{count} server settings", + "migrationNote": "Import requires this instance to have run all database migrations. Bundle format {bundleVersion} is validated against the current schema before import." + }, + "export": { + "title": "Export Full Configuration", + "description": "Export all boards, apps, integrations (with encrypted secrets), server settings, search engines, and groups as a portable JSON file. The encryption key is embedded so secrets can be restored on import.", + "action": "Export Configuration", + "warning": { + "title": "This export contains secrets", + "message": "The downloaded JSON includes your full Homarr configuration. Integration credentials stay encrypted, but the file also embeds your instance encryption key so another Homarr instance can decrypt them on import.", + "storage": "Store the file only in a safe place. Anyone with this file can read and reuse your integration secrets.", + "sharing": "Do not share it in chat, email, cloud folders without encryption, or public issue trackers." + }, + "notification": { + "success": { + "title": "Export successful", + "message": "Your configuration has been exported" + }, + "error": { + "title": "Export failed", + "message": "Failed to export configuration" + } + } + }, + "importConfig": { + "title": "Import Homarr Configuration", + "description": "Import a previously exported Homarr configuration file. Existing boards, apps, and integrations with the same identity will be reused. Secrets will be re-encrypted with this instance's key.", + "fileLabel": "Configuration file (.json)", + "invalidFile": "Invalid Homarr configuration file", + "notification": { + "success": { + "title": "Import successful", + "message": "Configuration has been imported" + }, + "error": { + "title": "Import failed", + "message": "Failed to import configuration" + } + } + }, + "importHomepage": { + "title": "Import Homepage services.yaml", + "description": "Import a Homepage dashboard services.yaml file to create a new board with apps and integrations.", + "fileLabel": "Homepage services.yaml file", + "invalidFile": "Invalid services.yaml file", + "boardNameLabel": "Board name", + "createIntegrations": "Create integrations from Homepage widgets", + "envVariables": { + "label": "Environment variables", + "description": "Your services.yaml contains template variables. Fill in the values they should resolve to." + }, + "notification": { + "success": { + "title": "Import successful", + "message": "Homepage services have been imported as a new board" + }, + "error": { + "title": "Import failed", + "message": "Failed to import Homepage services" + } + } + } + }, "settings": { "title": "Settings", "notification": { @@ -3700,6 +3873,33 @@ "defaultLocale": { "label": "Default language" } + }, + "docker": { + "title": "Docker Discovery", + "targetBoardName": { + "label": "Default target board", + "description": "Board name to place discovered apps on when no board label is set" + }, + "readHomepageLabels": { + "label": "Read Homepage labels", + "description": "Fall back to homepage.* Docker labels when homarr.* labels are not present" + }, + "createIntegrations": { + "label": "Auto-create integrations", + "description": "Automatically create integrations when a container has homarr.integration or homepage widget labels" + }, + "pruneRemoved": { + "label": "Remove stale apps", + "description": "Delete apps from boards when their source container is no longer running" + }, + "defaultItemWidth": { + "label": "Default item width", + "description": "Grid column width for discovered app tiles" + }, + "defaultItemHeight": { + "label": "Default item height", + "description": "Grid row height for discovered app tiles" + } } } }, @@ -3944,6 +4144,34 @@ }, "docker": { "title": "Containers", + "discovery": { + "title": "Label Discovery", + "sync": { + "label": "Sync now", + "notification": { + "success": { + "title": "Discovery sync complete", + "message": "Created {created}, updated {updated} services" + }, + "error": { + "title": "Discovery sync failed", + "message": "Could not sync discovered services. Check server logs for details." + } + } + }, + "preview": { + "label": "Scan", + "title": "Discovered services", + "idle": "Press Scan to find running containers with Homarr or Homepage labels", + "empty": "No containers with homarr or homepage labels were found" + }, + "field": { + "name": "Name", + "group": "Group", + "href": "URL", + "host": "Host" + } + }, "table": { "updated": "Updated {when}", "search": "Seach {count} containers", @@ -4435,6 +4663,9 @@ "hostnames": { "label": "Hostnames" } + }, + "import-export": { + "label": "Import / Export" } }, "settings": { diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 2ce53a1eb..81de4232f 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -94,3 +94,12 @@ export const boardCreateSchema = z.object({ }); export const boardSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions)); + +export const boardExportBundleSchema = z.object({ + id: z.string(), +}); + +export const boardImportBundleSchema = z.object({ + content: z.string().min(1), + name: boardNameSchema, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db1a11a3a..220e5d150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,9 +273,6 @@ catalogs: '@vitest/ui': specifier: ^4.1.7 version: 4.1.7 - '@xterm/addon-canvas': - specifier: ^0.7.0 - version: 0.7.0 '@xterm/addon-fit': specifier: 0.11.0 version: 0.11.0 @@ -655,6 +652,9 @@ importers: '@homarr/auth': specifier: workspace:^0.1.0 version: link:../../packages/auth + '@homarr/board-portability': + specifier: workspace:^0.1.0 + version: link:../../packages/board-portability '@homarr/boards': specifier: workspace:^0.1.0 version: link:../../packages/boards @@ -781,9 +781,6 @@ importers: '@trpc/server': specifier: 'catalog:' version: 11.17.0(typescript@5.9.3) - '@xterm/addon-canvas': - specifier: 'catalog:' - version: 0.7.0(@xterm/xterm@6.0.0) '@xterm/addon-fit': specifier: 'catalog:' version: 0.11.0 @@ -1067,6 +1064,9 @@ importers: '@homarr/auth': specifier: workspace:^0.1.0 version: link:../auth + '@homarr/board-portability': + specifier: workspace:^0.1.0 + version: link:../board-portability '@homarr/common': specifier: workspace:^0.1.0 version: link:../common @@ -1226,6 +1226,43 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/board-portability: + dependencies: + '@homarr/common': + specifier: workspace:^0.1.0 + version: link:../common + '@homarr/core': + specifier: workspace:^0.1.0 + version: link:../core + '@homarr/db': + specifier: workspace:^0.1.0 + version: link:../db + '@homarr/definitions': + specifier: workspace:^0.1.0 + version: link:../definitions + '@homarr/validation': + specifier: workspace:^0.1.0 + version: link:../validation + superjson: + specifier: 'catalog:' + version: 2.2.6 + yaml: + specifier: 'catalog:' + version: 2.9.0 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@homarr/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.7(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.12.4)(typescript@5.9.3))(vite@7.1.12(@types/node@24.12.4)(jiti@1.21.7)(sass@1.100.0)(sugarss@5.0.0(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) + packages/boards: dependencies: '@homarr/api': @@ -1253,6 +1290,9 @@ importers: '@homarr/auth': specifier: workspace:^0.1.0 version: link:../auth + '@homarr/board-portability': + specifier: workspace:^0.1.0 + version: link:../board-portability '@homarr/common': specifier: workspace:^0.1.0 version: link:../common @@ -1404,6 +1444,9 @@ importers: '@homarr/definitions': specifier: workspace:^0.1.0 version: link:../definitions + '@homarr/docker': + specifier: workspace:^0.1.0 + version: link:../docker '@homarr/icons': specifier: workspace:^0.1.0 version: link:../icons @@ -1570,9 +1613,18 @@ importers: '@homarr/core': specifier: workspace:^0.1.0 version: link:../core + '@homarr/db': + specifier: workspace:^0.1.0 + version: link:../db + '@homarr/definitions': + specifier: workspace:^0.1.0 + version: link:../definitions dockerode: specifier: 'catalog:' version: 5.0.0 + superjson: + specifier: 'catalog:' + version: 2.2.6 devDependencies: '@homarr/tsconfig': specifier: workspace:^0.1.0 @@ -1583,6 +1635,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.7(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.12.4)(typescript@5.9.3))(vite@7.1.12(@types/node@24.12.4)(jiti@1.21.7)(sass@1.100.0)(sugarss@5.0.0(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) packages/form: dependencies: @@ -1817,6 +1872,9 @@ importers: '@homarr/api': specifier: workspace:^0.1.0 version: link:../api + '@homarr/board-portability': + specifier: workspace:^0.1.0 + version: link:../board-portability '@homarr/common': specifier: workspace:^0.1.0 version: link:../common @@ -5569,11 +5627,6 @@ packages: engines: {node: '>=10.0.0'} deprecated: this version has critical issues, please update to the latest version - '@xterm/addon-canvas@0.7.0': - resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} @@ -6526,9 +6579,6 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -12956,10 +13006,6 @@ snapshots: '@xmldom/xmldom@0.8.10': {} - '@xterm/addon-canvas@0.7.0(@xterm/xterm@6.0.0)': - dependencies: - '@xterm/xterm': 6.0.0 - '@xterm/addon-fit@0.11.0': {} '@xterm/xterm@6.0.0': {} @@ -13785,8 +13831,6 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.0.0: {} - es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: @@ -17204,7 +17248,7 @@ snapshots: '@vitest/snapshot': 4.1.7 '@vitest/spy': 4.1.7 '@vitest/utils': 4.1.7 - es-module-lexer: 2.0.0 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a22e6edf1..1bfae4bbf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -93,7 +93,6 @@ catalog: "@vitejs/plugin-react": ^5.2.0 "@vitest/coverage-v8": ^4.1.7 "@vitest/ui": ^4.1.7 - "@xterm/addon-canvas": ^0.7.0 "@xterm/addon-fit": 0.11.0 "@xterm/xterm": ^6.0.0 adm-zip: 0.5.17