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