Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79cb9277e6 | ||
|
|
484b8cf9f1 | ||
|
|
b3cdab579b | ||
|
|
44a0c6ce54 | ||
|
|
7f05e0325d | ||
|
|
f71b145403 | ||
|
|
607fd498f4 | ||
|
|
3f2fb54cdf | ||
|
|
69412f2d10 | ||
|
|
f6d5f97933 | ||
|
|
a447a9f615 | ||
|
|
87d3f6a0a1 | ||
|
|
3d2c4979a6 | ||
|
|
79b27815fe | ||
|
|
f2705eeb57 | ||
|
|
76eacf8353 | ||
|
|
0c14e715a0 | ||
|
|
ac37d2c73f | ||
|
|
e00ea624cd | ||
|
|
20b62c42e3 | ||
|
|
59971991c7 | ||
|
|
a629681ae5 | ||
|
|
8a1ead3674 | ||
|
|
9c05f44916 |
No files matched your search
@@ -1,5 +1,4 @@
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
.dockerignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
@@ -15,10 +14,3 @@ apps/nextjs/.next
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.docker-data
|
||||
.docker-cache
|
||||
coverage
|
||||
.cursor
|
||||
agent-transcripts
|
||||
**/*.spec.ts
|
||||
**/*.test.ts
|
||||
@@ -72,7 +72,3 @@ en.d.json.ts
|
||||
# personal agent config
|
||||
AGENTS.md
|
||||
.opencode
|
||||
|
||||
# local docker dev
|
||||
.docker-data/
|
||||
.docker-cache/
|
||||
@@ -5,9 +5,21 @@ import "@homarr/common/env";
|
||||
import "@homarr/core/infrastructure/logs/env";
|
||||
import "@homarr/docker/env";
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const homarrVersion = (() => {
|
||||
for (const candidate of ["../../package.json", "./package.json"]) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
||||
if (pkg.name === "homarr") return pkg.version as string;
|
||||
} catch {}
|
||||
}
|
||||
return "unknown";
|
||||
})();
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin({
|
||||
experimental: {
|
||||
@@ -17,6 +29,9 @@ const withNextIntl = createNextIntlPlugin({
|
||||
});
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: {
|
||||
HOMARR_VERSION: homarrVersion,
|
||||
},
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
// react compiler breaks mantine-react-table, so disabled for now
|
||||
@@ -27,7 +42,7 @@ const nextConfig: NextConfig = {
|
||||
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
||||
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
||||
*/
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"@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",
|
||||
@@ -65,6 +64,7 @@
|
||||
"@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:",
|
||||
@@ -72,9 +72,13 @@
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"flag-icons": "catalog:",
|
||||
"glob": "catalog:",
|
||||
"adm-zip": "catalog:",
|
||||
"better-sqlite3": "catalog:",
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"mantine-react-table": "catalog:",
|
||||
"next": "catalog:",
|
||||
@@ -85,6 +89,7 @@
|
||||
"react-error-boundary": "catalog:",
|
||||
"react-simple-code-editor": "catalog:",
|
||||
"sass": "catalog:",
|
||||
"sql.js": "catalog:",
|
||||
"superjson": "catalog:",
|
||||
"swagger-ui-react": "catalog:",
|
||||
"use-deep-compare-effect": "catalog:",
|
||||
@@ -93,6 +98,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/adm-zip": "catalog:",
|
||||
"@types/better-sqlite3": "catalog:",
|
||||
"@types/chroma-js": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/prismjs": "catalog:",
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -6,14 +6,11 @@ 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");
|
||||
};
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
|
||||
|
||||
import { FileInfoCard } from "./file-info-card";
|
||||
|
||||
export const InitConfigImport = () => {
|
||||
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const hasOpenedRef = useRef(false);
|
||||
|
||||
const {
|
||||
mutate: runPreview,
|
||||
data: preview,
|
||||
isPending: previewLoading,
|
||||
reset: resetPreview,
|
||||
} = clientApi.import.previewInitialConfigImport.useMutation();
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.import.importInitialConfigImport.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOpenedRef.current) return;
|
||||
hasOpenedRef.current = true;
|
||||
setTimeout(() => inputRef.current?.click(), 100);
|
||||
}, []);
|
||||
|
||||
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"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
|
||||
void selectedFile.text().then((content) => {
|
||||
setFileContent(content);
|
||||
setFile(selectedFile as FileWithPath);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack w="100%" mb="sm">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{file && (
|
||||
<>
|
||||
<FileInfoCard
|
||||
file={file}
|
||||
onRemove={() => {
|
||||
setFile(null);
|
||||
setFileContent("");
|
||||
}}
|
||||
/>
|
||||
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
|
||||
<Button
|
||||
loading={isPending}
|
||||
disabled={!importAllowed || previewLoading}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{t("init.step.import.configImport.action")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!file && (
|
||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => inputRef.current?.click()}>
|
||||
{t("init.step.import.configImport.selectFile")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -11,20 +11,8 @@ import { InitialOldmarrImport } from "@homarr/old-import/components";
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
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 <InitConfigImport />;
|
||||
}
|
||||
|
||||
return <LegacyInitImport />;
|
||||
};
|
||||
|
||||
const LegacyInitImport = () => {
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
|
||||
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"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);
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { IconFileImport, IconJson, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { InitSqliteRestore } from "./sqlite-restore";
|
||||
import { InitStartButton } from "./next-button";
|
||||
|
||||
export const InitStart = async () => {
|
||||
const tStart = await getScopedI18n("init.step.start");
|
||||
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
@@ -23,18 +26,11 @@ export const InitStart = async () => {
|
||||
</InitStartButton>
|
||||
<InitStartButton
|
||||
preferredStep="import"
|
||||
importMode="legacy"
|
||||
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.importOldmarr")}
|
||||
</InitStartButton>
|
||||
<InitStartButton
|
||||
preferredStep="import"
|
||||
importMode="config"
|
||||
icon={<IconJson color={getMantineColor("blue", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.importConfig")}
|
||||
</InitStartButton>
|
||||
{isSqlite && <InitSqliteRestore />}
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
|
||||
@@ -7,28 +7,15 @@ 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,
|
||||
importMode,
|
||||
}: PropsWithChildren<InitStartButtonProps>) => {
|
||||
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
|
||||
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
if (importMode) {
|
||||
setInitImportMode(importMode);
|
||||
} else {
|
||||
clearInitImportMode();
|
||||
}
|
||||
await mutateAsync({ preferredStep });
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Collapse, Stack, Text } from "@mantine/core";
|
||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||
|
||||
export const InitSqliteRestore = () => {
|
||||
const t = useScopedI18n("init.step.start.action");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
{t("backToOptions")}
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("restoreSqliteDescription")}
|
||||
</Text>
|
||||
<DatabaseRestoreFlow variant="standalone" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setExpanded(true)}
|
||||
variant="default"
|
||||
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("restoreSqlite")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+3
-19
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDeviceMobile, IconDownload, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDeviceMobile, 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, ExportBoardModal } from "@homarr/modals-collection";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
@@ -28,10 +28,7 @@ interface BoardCardMenuDropdownProps {
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
const t = useScopedI18n("management.page.board.action" as never) as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, unknown>,
|
||||
) => string;
|
||||
const t = useScopedI18n("management.page.board.action");
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
@@ -39,7 +36,6 @@ 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 () => {
|
||||
@@ -91,15 +87,6 @@ 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 (
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||
@@ -108,9 +95,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
|
||||
{t("setMobileHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={handleExportBoard} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("export.label")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("duplicate.label")}
|
||||
|
||||
@@ -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() as unknown as (key: string) => string;
|
||||
const t = useI18n();
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -156,10 +157,10 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
|
||||
label: t("items.tools.items.backup"),
|
||||
icon: IconDatabaseExport,
|
||||
href: "/manage/tools/import-export",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
href: "/manage/tools/backup",
|
||||
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"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 (
|
||||
<CommonSettingsForm settingKey="docker" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<TextInput
|
||||
{...form.getInputProps("targetBoardName")}
|
||||
value={form.getInputProps("targetBoardName").value ?? ""}
|
||||
label={tDocker("targetBoardName.label")}
|
||||
description={tDocker("targetBoardName.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("readHomepageLabels", { type: "checkbox" })}
|
||||
label={tDocker("readHomepageLabels.label")}
|
||||
description={tDocker("readHomepageLabels.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
|
||||
label={tDocker("createIntegrations.label")}
|
||||
description={tDocker("createIntegrations.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("pruneRemoved", { type: "checkbox" })}
|
||||
label={tDocker("pruneRemoved.label")}
|
||||
description={tDocker("pruneRemoved.description")}
|
||||
/>
|
||||
<NumberInput
|
||||
{...form.getInputProps("defaultItemWidth")}
|
||||
label={tDocker("defaultItemWidth.label")}
|
||||
description={tDocker("defaultItemWidth.description")}
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
<NumberInput
|
||||
{...form.getInputProps("defaultItemHeight")}
|
||||
label={tDocker("defaultItemHeight.label")}
|
||||
description={tDocker("defaultItemHeight.description")}
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
@@ -5,15 +5,12 @@ 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";
|
||||
|
||||
@@ -62,12 +59,6 @@ export default async function SettingsPage() {
|
||||
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||
</Stack>
|
||||
{env.ENABLE_DOCKER && (
|
||||
<Stack>
|
||||
<Title order={2}>{(tSettings as unknown as (key: string) => string)("section.docker.title")}</Title>
|
||||
<DockerSettingsForm defaultValues={serverSettings.docker} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
|
||||
|
||||
export const BackupExportCard = () => {
|
||||
const t = useScopedI18n("management.page.tool.backup.export");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/backup/export");
|
||||
if (!response.ok) {
|
||||
const errorMessage = await response.json().then(
|
||||
(data) => data.error,
|
||||
() => `Server returned ${response.status}`,
|
||||
);
|
||||
throw new Error(errorMessage ?? t("error"));
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setShowConfirm(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm">
|
||||
<IconDatabaseExport size={24} />
|
||||
<Title order={4}>{t("title")}</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showConfirm ? (
|
||||
<DangerousActionConfirmation
|
||||
title={t("confirm.title")}
|
||||
warningTitle={t("confirm.warningTitle")}
|
||||
warningBody={t("confirm.warningBody")}
|
||||
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
|
||||
submitLabel={t("confirm.submit")}
|
||||
submitIcon={<IconDownload size={16} />}
|
||||
cancelLabel={t("confirm.cancel")}
|
||||
onConfirm={handleExport}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
disabled={loading}
|
||||
color="orange"
|
||||
/>
|
||||
) : (
|
||||
<Group>
|
||||
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
|
||||
{t("button")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
||||
|
||||
export const BackupImportCard = () => {
|
||||
const t = useScopedI18n("management.page.tool.backup.import");
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<IconDatabaseImport size={24} />
|
||||
<Title order={4}>{t("title")}</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<DatabaseRestoreFlow />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { BackupExportCard } from "./_components/backup-export-card";
|
||||
import { BackupImportCard } from "./_components/backup-import-card";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.tool.backup");
|
||||
|
||||
return {
|
||||
title: createMetaTitle(t("metaTitle")),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BackupPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.tool.backup");
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("title")}</Title>
|
||||
<BackupExportCard />
|
||||
<BackupImportCard />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
@@ -44,34 +45,34 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const terminal = new Terminal({
|
||||
const canvasAddon = new CanvasAddon();
|
||||
|
||||
terminalRef.current = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
});
|
||||
terminal.open(ref.current);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminalRef.current = terminal;
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
|
||||
isTerminalReadyRef.current = true;
|
||||
const terminal = terminalRef.current;
|
||||
pendingLogsRef.current.forEach((data) => terminal.write(data));
|
||||
pendingLogsRef.current = [];
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
|
||||
const fitTimeout = setTimeout(() => fitAddon.fit());
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
|
||||
resizeObserver.observe(ref.current);
|
||||
setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(fitTimeout);
|
||||
resizeObserver.disconnect();
|
||||
isTerminalReadyRef.current = false;
|
||||
pendingLogsRef.current = [];
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [writeToTerminal]);
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
"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, unknown>,
|
||||
) => 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 (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>{tDiscovery("title")}</Title>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
loading={isLoadingPreview}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{tDiscovery("preview.label")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
loading={isSyncing}
|
||||
onClick={() => syncDiscovery()}
|
||||
disabled={services.length === 0}
|
||||
>
|
||||
{tDiscovery("sync.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!isFetched ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{tDiscovery("preview.idle")}
|
||||
</Text>
|
||||
) : services.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{tDiscovery("preview.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{tDiscovery("field.name")}</Table.Th>
|
||||
<Table.Th>{tDiscovery("field.group")}</Table.Th>
|
||||
<Table.Th>{tDiscovery("field.href")}</Table.Th>
|
||||
<Table.Th>{tDiscovery("field.host")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{services.map((service) => (
|
||||
<Table.Tr key={`${service.host}/${service.containerId}`}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{service.icon && (
|
||||
<Avatar size="sm" radius="sm" src={service.icon}>
|
||||
{service.name.charAt(0)}
|
||||
</Avatar>
|
||||
)}
|
||||
<Text size="sm">{service.name}</Text>
|
||||
{service.integrationKind && (
|
||||
<Badge size="xs" variant="light">
|
||||
{service.integrationKind}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="outline" size="sm">
|
||||
{service.group}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{service.href}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{service.host}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
"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, unknown>) => 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 (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>{t("management.page.importExport.export.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.export.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Popover
|
||||
opened={popoverOpened}
|
||||
onChange={setPopoverOpened}
|
||||
width={420}
|
||||
position="bottom-end"
|
||||
withArrow
|
||||
shadow="md"
|
||||
>
|
||||
<Popover.Target>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconDatabaseExport size="1rem" />}
|
||||
loading={isPending}
|
||||
disabled={isPending}
|
||||
onClick={() => setPopoverOpened(true)}
|
||||
>
|
||||
{t("management.page.importExport.export.action")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size="1.25rem" stroke={1.5} />}
|
||||
title={t("management.page.importExport.export.warning.title")}
|
||||
>
|
||||
<List size="sm" spacing="xs">
|
||||
{exportWarningPointKeys.map((key) => (
|
||||
<List.Item key={key}>{t(key)}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("management.page.importExport.summary.title")}
|
||||
</Text>
|
||||
{previewLoading && (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.preview.exportLoading")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{preview && <ConfigSummaryList counts={preview} t={t} />}
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closePopover}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red.9"
|
||||
onClick={handleConfirmExport}
|
||||
disabled={previewLoading}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("common.action.continue")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
"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, unknown>) => string;
|
||||
const [file, setFile] = useState<File | null>(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 (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>{t("management.page.importExport.importConfig.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.importConfig.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
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")}
|
||||
/>
|
||||
|
||||
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
|
||||
|
||||
{importResult && (
|
||||
<Alert color="green" icon={<IconCheck />}>
|
||||
<List size="sm">
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.boardsImported", { count: importResult.boards })}</List.Item>
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.appsCreated", { count: importResult.apps })}</List.Item>
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.integrationsCreated", { count: importResult.integrations })}</List.Item>
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.groupsCreated", { count: importResult.groups })}</List.Item>
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="end">
|
||||
<Button loading={isPending} disabled={!file || !importAllowed || previewLoading} onClick={() => void handleImport()}>
|
||||
{t("common.action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,181 +0,0 @@
|
||||
"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<string[]>([]);
|
||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.file(),
|
||||
boardName: boardNameSchema,
|
||||
createIntegrations: z.boolean(),
|
||||
}),
|
||||
{
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
boardName: "",
|
||||
createIntegrations: true,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
void (async () => {
|
||||
if (values.file === previous.file) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!values.file) return;
|
||||
|
||||
const content = await values.file.text();
|
||||
setRawContent(content);
|
||||
|
||||
const vars = extractHomepageEnvVariables(content);
|
||||
setEnvVars(vars);
|
||||
setEnvValues(Object.fromEntries(vars.map((v: 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 (
|
||||
<Card withBorder>
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
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"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
})();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>{t("management.page.importExport.importHomepage.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.importHomepage.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
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 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("management.page.importExport.importHomepage.envVariables.label")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("management.page.importExport.importHomepage.envVariables.description")}
|
||||
</Text>
|
||||
{envVars.map((varName) => (
|
||||
<TextInput
|
||||
key={varName}
|
||||
label={<Code>{`{{${varName}}}`}</Code>}
|
||||
placeholder={varName.replace("HOMEPAGE_VAR_", "").toLowerCase().replaceAll("_", ".")}
|
||||
value={envValues[varName] ?? ""}
|
||||
onChange={(event) => {
|
||||
setEnvValues((prev) => ({ ...prev, [varName]: event.target.value }));
|
||||
}}
|
||||
withAsterisk
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={t("management.page.importExport.importHomepage.boardNameLabel")}
|
||||
{...form.getInputProps("boardName")}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t("management.page.importExport.importHomepage.createIntegrations")}
|
||||
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} disabled={!allEnvVarsFilled}>
|
||||
{t("common.action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
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 (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<ExportConfigCard />
|
||||
<ImportConfigCard />
|
||||
<ImportHomepageCard />
|
||||
{env.ENABLE_DOCKER && <DockerDiscoveryPanel />}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
|
||||
@@ -14,8 +15,8 @@ export const TerminalComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { activeLevels, fontSize } = useLogContext();
|
||||
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const terminalRef = useRef<Terminal>(null);
|
||||
const fitAddonRef = useRef<FitAddon>(null);
|
||||
|
||||
clientApi.log.subscribe.useSubscription(
|
||||
{
|
||||
@@ -37,31 +38,28 @@ export const TerminalComponent = () => {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const terminal = new Terminal({
|
||||
const canvasAddon = new CanvasAddon();
|
||||
|
||||
terminalRef.current = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize,
|
||||
});
|
||||
terminal.open(ref.current);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminalRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
|
||||
const fitTerminal = () => fitAddon.fit();
|
||||
const fitTimeout = setTimeout(fitTerminal);
|
||||
|
||||
const resizeObserver = new ResizeObserver(fitTerminal);
|
||||
resizeObserver.observe(ref.current);
|
||||
setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(fitTimeout);
|
||||
resizeObserver.disconnect();
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import AdmZip from "adm-zip";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (dbEnv.DRIVER !== "better-sqlite3") {
|
||||
return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbPath = dbEnv.URL as string | undefined;
|
||||
if (!dbPath || !fs.existsSync(dbPath)) {
|
||||
return NextResponse.json({ error: "Database file not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite");
|
||||
let sourceDb: InstanceType<typeof Database> | null = null;
|
||||
|
||||
try {
|
||||
sourceDb = new Database(dbPath, { readonly: true });
|
||||
sourceDb.pragma("wal_checkpoint(TRUNCATE)");
|
||||
sourceDb.exec(`VACUUM INTO '${tempPath}'`);
|
||||
sourceDb.close();
|
||||
sourceDb = null;
|
||||
|
||||
const dbBuffer = fs.readFileSync(tempPath);
|
||||
|
||||
const metadata = {
|
||||
homarrVersion: process.env.HOMARR_VERSION ?? "unknown",
|
||||
exportedAt: new Date().toISOString(),
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: env.SECRET_ENCRYPTION_KEY,
|
||||
};
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", dbBuffer);
|
||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2)));
|
||||
|
||||
const zipBuffer = zip.toBuffer();
|
||||
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
const filename = `homarr-backup-${date}.zip`;
|
||||
|
||||
return new NextResponse(zipBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": zipBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[backup/export] Export failed:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 });
|
||||
} finally {
|
||||
sourceDb?.close();
|
||||
try {
|
||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||
} catch (cleanupErr) {
|
||||
console.error("[backup/export] Failed to clean up temp file:", cleanupErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import AdmZip from "adm-zip";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { findMigrationsFolder } from "../shared";
|
||||
|
||||
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
||||
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
||||
if (importedKeyHex === currentKeyHex) return;
|
||||
|
||||
if (!HEX_KEY_REGEX.test(importedKeyHex)) {
|
||||
throw new Error("Imported encryption key is not a valid 64-character hex string");
|
||||
}
|
||||
|
||||
const oldKey = Buffer.from(importedKeyHex, "hex");
|
||||
const newKey = Buffer.from(currentKeyHex, "hex");
|
||||
|
||||
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
|
||||
integration_id: string;
|
||||
kind: string;
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
|
||||
const transaction = tempDb.transaction(() => {
|
||||
for (const row of rows) {
|
||||
const parts = row.value.split(".");
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(
|
||||
`Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`,
|
||||
);
|
||||
}
|
||||
const [data, dataIv] = parts as [string, string];
|
||||
|
||||
try {
|
||||
const iv = Buffer.from(dataIv, "hex");
|
||||
const encrypted = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
||||
|
||||
const newIv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv);
|
||||
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
||||
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
||||
|
||||
updateStmt.run(newValue, row.integration_id, row.kind);
|
||||
} catch {
|
||||
throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
transaction();
|
||||
};
|
||||
|
||||
const isOnboardingActiveAsync = async (): Promise<boolean> => {
|
||||
const onboardingRow = await db.query.onboarding.findFirst();
|
||||
if (!onboardingRow) return false;
|
||||
return onboardingRow.step === "start";
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
|
||||
|
||||
if (!isAdmin && !isOnboarding) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (dbEnv.DRIVER !== "better-sqlite3") {
|
||||
return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbPath = dbEnv.URL as string | undefined;
|
||||
if (!dbPath) {
|
||||
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await req.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(Buffer.from(arrayBuffer));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
|
||||
if (missingEntries.length > 0) {
|
||||
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const migrationsFolder = findMigrationsFolder();
|
||||
if (!migrationsFolder) {
|
||||
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
|
||||
let tempDb: InstanceType<typeof Database> | null = null;
|
||||
|
||||
try {
|
||||
const dbEntry = zip.getEntry("db.sqlite")!;
|
||||
fs.writeFileSync(tempPath, dbEntry.getData());
|
||||
|
||||
tempDb = new Database(tempPath);
|
||||
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
|
||||
migrate(drizzleDb, { migrationsFolder });
|
||||
|
||||
const metadataEntry = zip.getEntry("metadata.json")!;
|
||||
const metadata = JSON.parse(metadataEntry.getData().toString());
|
||||
|
||||
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
|
||||
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
|
||||
|
||||
const secretCount = (
|
||||
tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }
|
||||
).count;
|
||||
|
||||
if (secretCount > 0 && !importedKey) {
|
||||
throw new Error(
|
||||
"Backup contains integration secrets but no encryption key. " +
|
||||
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
|
||||
);
|
||||
}
|
||||
|
||||
if (importedKey) {
|
||||
reEncryptSecrets(tempDb, importedKey);
|
||||
}
|
||||
|
||||
tempDb.close();
|
||||
tempDb = null;
|
||||
|
||||
fs.renameSync(tempPath, dbPath);
|
||||
|
||||
for (const suffix of ["-wal", "-shm"] as const) {
|
||||
try {
|
||||
const sidecar = `${dbPath}${suffix}`;
|
||||
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
||||
} catch (cleanupErr) {
|
||||
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Database restored, restarting server...");
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Restore failed:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
|
||||
} finally {
|
||||
tempDb?.close();
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// temp file may have been renamed already
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { findMigrationsFolder } from "../shared";
|
||||
|
||||
interface JournalEntry {
|
||||
idx: number;
|
||||
version: string;
|
||||
when: number;
|
||||
tag: string;
|
||||
breakpoints: boolean;
|
||||
}
|
||||
|
||||
interface MigrationFile {
|
||||
idx: number;
|
||||
tag: string;
|
||||
sql: string;
|
||||
when: number;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const migrationsFolder = findMigrationsFolder();
|
||||
if (!migrationsFolder) {
|
||||
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
return NextResponse.json({ error: "Migration journal not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] };
|
||||
|
||||
const migrations: MigrationFile[] = [];
|
||||
for (const entry of journal.entries) {
|
||||
const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`);
|
||||
if (!fs.existsSync(sqlPath)) continue;
|
||||
|
||||
migrations.push({
|
||||
idx: entry.idx,
|
||||
tag: entry.tag,
|
||||
sql: fs.readFileSync(sqlPath, "utf-8"),
|
||||
when: entry.when,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ total: migrations.length, migrations },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[backup/migrations] Failed to read migrations:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const migrationsFolderCandidates = [
|
||||
path.join(process.cwd(), "db/migrations/sqlite"),
|
||||
path.join(process.cwd(), "packages/db/migrations/sqlite"),
|
||||
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
|
||||
"/app/db/migrations/sqlite",
|
||||
];
|
||||
|
||||
export const findMigrationsFolder = () =>
|
||||
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
@@ -0,0 +1,30 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const wasmCandidates = [
|
||||
path.join(process.cwd(), "public/sql-wasm.wasm"),
|
||||
path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"),
|
||||
path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"),
|
||||
path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"),
|
||||
];
|
||||
|
||||
let cachedWasm: Buffer | null = null;
|
||||
|
||||
export async function GET() {
|
||||
if (!cachedWasm) {
|
||||
const found = wasmCandidates.find((p) => fs.existsSync(p));
|
||||
if (!found) {
|
||||
return NextResponse.json({ error: "WASM binary not found" }, { status: 500 });
|
||||
}
|
||||
cachedWasm = fs.readFileSync(found);
|
||||
}
|
||||
|
||||
return new NextResponse(cachedWasm, {
|
||||
headers: {
|
||||
"Content-Type": "application/wasm",
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import AdmZip from "adm-zip";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
|
||||
|
||||
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
|
||||
|
||||
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
|
||||
const key = Buffer.from(keyHex, "hex");
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
|
||||
};
|
||||
|
||||
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
|
||||
const key = Buffer.from(keyHex, "hex");
|
||||
const [data, dataIv] = value.split(".") as [string, string];
|
||||
const iv = Buffer.from(dataIv, "hex");
|
||||
const encrypted = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
let decrypted = decipher.update(encrypted);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
};
|
||||
|
||||
const createTestDb = (dbPath: string) => {
|
||||
const sqlite = new Database(dbPath);
|
||||
const db = drizzle(sqlite, { casing: DB_CASING });
|
||||
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
return { db, sqlite };
|
||||
};
|
||||
|
||||
describe("SQLite backup", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("export ZIP structure", () => {
|
||||
it("should create a ZIP containing all required entries", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
||||
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
||||
sqlite.close();
|
||||
|
||||
const dbBuffer = fs.readFileSync(backupPath);
|
||||
const metadata = {
|
||||
homarrVersion: "test",
|
||||
exportedAt: new Date().toISOString(),
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: generateHexKey(),
|
||||
};
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", dbBuffer);
|
||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
|
||||
|
||||
const zipPath = path.join(tmpDir, "backup.zip");
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
const readBack = new AdmZip(zipPath);
|
||||
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
|
||||
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
|
||||
|
||||
const dbEntry = readBack.getEntry("db.sqlite");
|
||||
expect(dbEntry).toBeDefined();
|
||||
expect(dbEntry!.header.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should produce a valid SQLite database in the ZIP", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
||||
sqlite.close();
|
||||
|
||||
const backupDb = new Database(backupPath, { readonly: true });
|
||||
const tables = backupDb
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
|
||||
expect(tables.map((t) => t.name)).toContain("board");
|
||||
expect(tables.map((t) => t.name)).toContain("user");
|
||||
expect(tables.map((t) => t.name)).toContain("integrationSecret");
|
||||
|
||||
backupDb.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("import ZIP validation", () => {
|
||||
it("should detect missing required entries", () => {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const required = ["db.sqlite", "metadata.json"];
|
||||
const missing = required.filter((name) => !entries.includes(name));
|
||||
|
||||
expect(missing).toEqual(["metadata.json"]);
|
||||
});
|
||||
|
||||
it("should accept valid backup ZIP with all entries", () => {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const required = ["db.sqlite", "metadata.json"];
|
||||
const missing = required.filter((name) => !entries.includes(name));
|
||||
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should accept legacy backup ZIP with encryption-key.txt", () => {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
||||
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
|
||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const required = ["db.sqlite", "metadata.json"];
|
||||
const missing = required.filter((name) => !entries.includes(name));
|
||||
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret re-encryption", () => {
|
||||
it("should re-encrypt secrets from old key to new key", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
const oldKey = generateHexKey();
|
||||
const newKey = generateHexKey();
|
||||
const plaintext = "my-secret-password";
|
||||
|
||||
const encryptedValue = encryptWithKey(plaintext, oldKey);
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO "integration" ("id", "name", "url", "kind")
|
||||
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
||||
`);
|
||||
sqlite.exec(`
|
||||
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
||||
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
||||
`);
|
||||
|
||||
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
|
||||
expect(decryptedBefore).toBe(plaintext);
|
||||
|
||||
const oldKeyBuf = Buffer.from(oldKey, "hex");
|
||||
const newKeyBuf = Buffer.from(newKey, "hex");
|
||||
|
||||
const rows = sqlite
|
||||
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
|
||||
.all() as { integration_id: string; kind: string; value: string }[];
|
||||
|
||||
const updateStmt = sqlite.prepare(
|
||||
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const [data, dataIv] = row.value.split(".") as [string, string];
|
||||
const iv = Buffer.from(dataIv, "hex");
|
||||
const encrypted = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
||||
|
||||
const newIv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
|
||||
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
||||
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
||||
|
||||
updateStmt.run(newValue, row.integration_id, row.kind);
|
||||
}
|
||||
|
||||
const updatedRow = sqlite
|
||||
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
||||
.get("int-1") as { value: string };
|
||||
|
||||
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
||||
expect(decryptedAfter).toBe(plaintext);
|
||||
|
||||
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("should skip re-encryption when keys match", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
const sameKey = generateHexKey();
|
||||
const plaintext = "my-secret";
|
||||
const encryptedValue = encryptWithKey(plaintext, sameKey);
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO "integration" ("id", "name", "url", "kind")
|
||||
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
||||
`);
|
||||
sqlite.exec(`
|
||||
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
||||
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
||||
`);
|
||||
|
||||
const rowBefore = sqlite
|
||||
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
||||
.get("int-1") as { value: string };
|
||||
|
||||
expect(rowBefore.value).toBe(encryptedValue);
|
||||
|
||||
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VACUUM INTO consistency", () => {
|
||||
it("should produce a consistent copy via VACUUM INTO", () => {
|
||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
||||
const { sqlite } = createTestDb(dbPath);
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO "app" ("id", "name", "icon_url")
|
||||
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
|
||||
`);
|
||||
|
||||
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
|
||||
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
||||
|
||||
const backupDb = new Database(backupPath, { readonly: true });
|
||||
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
|
||||
|
||||
expect(app).toBeDefined();
|
||||
expect(app!.name).toBe("TestApp");
|
||||
|
||||
backupDb.close();
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration on imported DB", () => {
|
||||
it("should successfully migrate an imported database", () => {
|
||||
const sourcePath = path.join(tmpDir, "source.sqlite");
|
||||
const { sqlite: sourceDb } = createTestDb(sourcePath);
|
||||
|
||||
sourceDb.exec(`
|
||||
INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status")
|
||||
VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
|
||||
`);
|
||||
sourceDb.close();
|
||||
|
||||
const importPath = path.join(tmpDir, "import.sqlite");
|
||||
fs.copyFileSync(sourcePath, importPath);
|
||||
|
||||
const importDb = new Database(importPath);
|
||||
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
|
||||
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
|
||||
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
|
||||
expect(board).toBeDefined();
|
||||
expect(board!.name).toBe("TestBoard");
|
||||
|
||||
importDb.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryption key validation", () => {
|
||||
it("should reject invalid hex keys", () => {
|
||||
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
|
||||
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
for (const key of invalidKeys) {
|
||||
expect(hexKeyRegex.test(key)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept valid 64-char hex keys", () => {
|
||||
const validKey = generateHexKey();
|
||||
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
||||
expect(hexKeyRegex.test(validKey)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { BackupAnalysis } from "./types";
|
||||
import { PREVIEW_TABLE_KEYS } from "./types";
|
||||
|
||||
interface BackupPreviewPanelProps {
|
||||
analysis: BackupAnalysis;
|
||||
}
|
||||
|
||||
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore.preview");
|
||||
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
|
||||
const pendingCount = analysis.migrations.pending.length;
|
||||
const hasPendingMigrations = pendingCount > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="xl">
|
||||
<IconDatabase size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={5}>{t("title")}</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("exported", { date: exportDate })}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="light" color="gray" ml="auto">
|
||||
Homarr {analysis.metadata.homarrVersion}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Table striped withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("entity")}</Table.Th>
|
||||
<Table.Th ta="right">{t("count")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{PREVIEW_TABLE_KEYS.map((key) => (
|
||||
<Table.Tr key={key}>
|
||||
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap={4} justify="flex-end">
|
||||
{(analysis.counts[key] ?? 0) > 0 && (
|
||||
<IconCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
)}
|
||||
<Text size="sm" ff="monospace">{analysis.counts[key] ?? 0}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{analysis.boardNames.length > 0 && (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" fw={500}>{t("boards")}:</Text>
|
||||
{analysis.boardNames.map((name) => (
|
||||
<Badge key={name} variant="light" size="sm">{name}</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{hasPendingMigrations && (
|
||||
<Paper p="md" radius="md" withBorder bd="1px solid var(--mantine-color-orange-8)">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="orange" size="lg" radius="xl">
|
||||
<IconGitBranch size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
{pendingCount === 1
|
||||
? t("migrationsApplied", { count: String(pendingCount) })
|
||||
: t("migrationsAppliedPlural", { count: String(pendingCount) })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{pendingCount === 1
|
||||
? t("migrationsOlderVersion", { count: String(pendingCount) })
|
||||
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="light" color="orange" ml="auto">
|
||||
{t("migrationsPending", { count: String(pendingCount) })}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!hasPendingMigrations && (
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="green" size="lg" radius="xl">
|
||||
<IconGitBranch size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={500} c="dimmed">
|
||||
{t("migrationsUpToDate", { count: String(analysis.migrations.total) })}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react";
|
||||
|
||||
const CONFIRMATION_PHRASE = "I understand";
|
||||
|
||||
interface DangerousActionConfirmationProps {
|
||||
title: string;
|
||||
warningTitle: string;
|
||||
warningBody: string;
|
||||
typePrompt: string;
|
||||
submitLabel: string;
|
||||
submitIcon: ReactNode;
|
||||
cancelLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
disabled?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const DangerousActionConfirmation = ({
|
||||
title,
|
||||
warningTitle,
|
||||
warningBody,
|
||||
typePrompt,
|
||||
submitLabel,
|
||||
submitIcon,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
disabled,
|
||||
color = "red",
|
||||
}: DangerousActionConfirmationProps) => {
|
||||
const [value, setValue] = useState("");
|
||||
const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase();
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="md" withBorder bd={`1px solid var(--mantine-color-${color}-9)`}>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<IconShieldExclamation size={24} color={`var(--mantine-color-${color}-6)`} />
|
||||
<Text size="lg" fw={700} c={color}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>{warningTitle}</Text>
|
||||
<Text size="sm">{warningBody}</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{typePrompt}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
placeholder={CONFIRMATION_PHRASE}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
input: {
|
||||
fontFamily: "var(--mantine-font-family-monospace)",
|
||||
borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
color={color}
|
||||
leftSection={submitIcon}
|
||||
onClick={onConfirm}
|
||||
disabled={!isMatch || disabled}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
||||
|
||||
import "@mantine/dropzone/styles.css";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { RestoreStep } from "./types";
|
||||
import { BackupPreviewPanel } from "./backup-preview-panel";
|
||||
import { MigrationProgressPanel } from "./migration-progress-panel";
|
||||
import { RestoreConfirmation } from "./restore-confirmation";
|
||||
import { RestoreProgressPanel } from "./restore-progress-panel";
|
||||
import { useBackupAnalysis } from "./use-backup-analysis";
|
||||
|
||||
interface DatabaseRestoreFlowProps {
|
||||
variant?: "card" | "standalone";
|
||||
onRestoreComplete?: () => void;
|
||||
}
|
||||
|
||||
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore");
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const [step, setStep] = useState<RestoreStep>("upload");
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const apiDoneRef = useRef(false);
|
||||
const animDoneRef = useRef(false);
|
||||
const apiErrorRef = useRef<string | null>(null);
|
||||
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
(files: FileWithPath[]) => {
|
||||
const droppedFile = files[0];
|
||||
if (!droppedFile) return;
|
||||
setFile(droppedFile);
|
||||
setStep("preview");
|
||||
setImportError(null);
|
||||
void analyzeFile(droppedFile);
|
||||
},
|
||||
[analyzeFile],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setFile(null);
|
||||
setStep("upload");
|
||||
setImportError(null);
|
||||
apiDoneRef.current = false;
|
||||
animDoneRef.current = false;
|
||||
apiErrorRef.current = null;
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const tryFinalize = useCallback(() => {
|
||||
if (!apiDoneRef.current) return;
|
||||
|
||||
if (apiErrorRef.current) {
|
||||
setImportError(apiErrorRef.current);
|
||||
setStep("error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!animDoneRef.current) return;
|
||||
onRestoreComplete?.();
|
||||
window.location.reload();
|
||||
}, [onRestoreComplete]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setStep("restoring");
|
||||
setImportError(null);
|
||||
apiDoneRef.current = false;
|
||||
animDoneRef.current = false;
|
||||
apiErrorRef.current = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const data = await response.json();
|
||||
apiErrorRef.current = data.error ?? t("failed.title");
|
||||
} catch {
|
||||
apiErrorRef.current = `Server returned ${response.status}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiErrorRef.current = t("failed.title");
|
||||
} finally {
|
||||
apiDoneRef.current = true;
|
||||
tryFinalize();
|
||||
}
|
||||
}, [file, t, tryFinalize]);
|
||||
|
||||
const handleAnimationComplete = useCallback(() => {
|
||||
animDoneRef.current = true;
|
||||
tryFinalize();
|
||||
}, [tryFinalize]);
|
||||
|
||||
if (step === "upload") {
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
acceptColor="blue.6"
|
||||
rejectColor="red.6"
|
||||
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
|
||||
multiple={false}
|
||||
maxSize={1024 * 1024 * 256}
|
||||
radius="md"
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
<div>
|
||||
<Text size="xl" inline>
|
||||
{t("dropzone.title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" inline mt={7}>
|
||||
{t("dropzone.description")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "preview") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<IconFileZip size={20} />
|
||||
<div>
|
||||
<Text size="sm" fw={500}>{file?.name}</Text>
|
||||
<Text size="xs" c="dimmed">{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button variant="subtle" size="xs" onClick={handleClear}>
|
||||
{t("changeFile")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{loading && migrationProgress && (
|
||||
<MigrationProgressPanel progress={migrationProgress} />
|
||||
)}
|
||||
|
||||
{loading && !migrationProgress && (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{analysisError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("analysisError")}>
|
||||
{analysisError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
<BackupPreviewPanel analysis={analysis} />
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => setStep("confirm")}
|
||||
>
|
||||
{t("continueToRestore")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "confirm") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{analysis && <BackupPreviewPanel analysis={analysis} />}
|
||||
<RestoreConfirmation
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setStep("preview")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "restoring") {
|
||||
return (
|
||||
<RestoreProgressPanel
|
||||
active
|
||||
migrations={analysis?.migrations.pending ?? []}
|
||||
onComplete={handleAnimationComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "error") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("failed.title")} radius="md">
|
||||
<Text size="sm">{importError}</Text>
|
||||
</Alert>
|
||||
<Group>
|
||||
<Button variant="subtle" onClick={handleClear}>
|
||||
{t("tryAgain")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DatabaseRestoreFlow } from "./database-restore-flow";
|
||||
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
||||
export type { BackupAnalysis, RestoreStep } from "./types";
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { MigrationProgress } from "./use-backup-analysis";
|
||||
|
||||
interface MigrationProgressPanelProps {
|
||||
progress: MigrationProgress;
|
||||
}
|
||||
|
||||
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore.migration");
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
|
||||
<IconDatabase size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("title", { current: String(progress.current), total: String(progress.total) })}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box pl={4}>
|
||||
<Timeline active={progress.current - 1} bulletSize={20} lineWidth={2} color="blue">
|
||||
{Array.from({ length: progress.total }, (_, i) => {
|
||||
const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done");
|
||||
const isActive = i === progress.current - 1 && progress.phase === "applying";
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={i}
|
||||
bullet={
|
||||
isDone ? (
|
||||
<IconCheck size={12} />
|
||||
) : isActive ? (
|
||||
<Loader size={10} color="white" />
|
||||
) : (
|
||||
<IconLoader size={12} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Text size="xs" ff="monospace" c={isDone ? "dimmed" : isActive ? "white" : "dimmed"}>
|
||||
{i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
||||
|
||||
interface RestoreConfirmationProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
|
||||
|
||||
return (
|
||||
<DangerousActionConfirmation
|
||||
title={t("title")}
|
||||
warningTitle={t("warningTitle")}
|
||||
warningBody={t("warningBody")}
|
||||
typePrompt={t("typePrompt", { phrase: "I understand" })}
|
||||
submitLabel={t("submit")}
|
||||
submitIcon={<IconDatabaseImport size={16} />}
|
||||
cancelLabel={t("cancel")}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconDatabaseImport,
|
||||
IconFileZip,
|
||||
IconKey,
|
||||
IconLoader,
|
||||
IconRefresh,
|
||||
IconTransform,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { MigrationFile } from "./types";
|
||||
|
||||
const BASE_PHASE_DURATIONS: Record<string, number> = {
|
||||
extracting: 1200,
|
||||
encrypting: 1500,
|
||||
swapping: 800,
|
||||
restarting: 10000,
|
||||
};
|
||||
|
||||
const MIGRATION_STEP_DURATION = 100;
|
||||
|
||||
interface TimelineStep {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: Icon;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface RestoreProgressPanelProps {
|
||||
active: boolean;
|
||||
migrations: MigrationFile[];
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore.progress");
|
||||
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
|
||||
|
||||
const steps = useMemo((): TimelineStep[] => {
|
||||
const result: TimelineStep[] = [
|
||||
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
|
||||
];
|
||||
|
||||
if (migrations.length > 0) {
|
||||
for (const migration of migrations) {
|
||||
result.push({
|
||||
key: `migration-${migration.idx}`,
|
||||
label: migration.tag,
|
||||
icon: IconDatabaseImport,
|
||||
duration: MIGRATION_STEP_DURATION,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
key: "migrating",
|
||||
label: t("migrating"),
|
||||
icon: IconDatabaseImport,
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
|
||||
result.push(
|
||||
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! },
|
||||
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! },
|
||||
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! },
|
||||
);
|
||||
|
||||
return result;
|
||||
}, [migrations, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (cancelled) return;
|
||||
setCurrentStepIdx(i);
|
||||
await new Promise((r) => setTimeout(r, steps[i]!.duration));
|
||||
}
|
||||
if (!cancelled) {
|
||||
onComplete?.();
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [active, onComplete, steps]);
|
||||
|
||||
if (!active && currentStepIdx === -1) return null;
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" color="blue" />
|
||||
<Text size="sm" fw={600}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box pl={4}>
|
||||
<Timeline
|
||||
active={currentStepIdx}
|
||||
bulletSize={24}
|
||||
lineWidth={2}
|
||||
color="blue"
|
||||
>
|
||||
{steps.map((step, i) => {
|
||||
const isDone = i < currentStepIdx;
|
||||
const isActive = i === currentStepIdx;
|
||||
const isMigration = step.key.startsWith("migration-");
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={step.key}
|
||||
bullet={
|
||||
isDone ? (
|
||||
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
|
||||
<IconCheck size={14} />
|
||||
</ThemeIcon>
|
||||
) : isActive ? (
|
||||
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
|
||||
<Loader size={12} color="white" />
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
|
||||
<IconLoader size={14} />
|
||||
</ThemeIcon>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Group gap="xs" mt={-4}>
|
||||
<step.icon size={16} color={isDone ? "var(--mantine-color-green-6)" : isActive ? "var(--mantine-color-blue-5)" : "var(--mantine-color-dimmed)"} />
|
||||
<Text
|
||||
size={isMigration ? "xs" : "sm"}
|
||||
fw={isActive ? 600 : 400}
|
||||
ff={isMigration ? "monospace" : undefined}
|
||||
c={isDone ? "green" : isActive ? "white" : "dimmed"}
|
||||
>
|
||||
{step.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface BackupMetadata {
|
||||
homarrVersion: string;
|
||||
exportedAt: string;
|
||||
dbDialect: string;
|
||||
encryptionKey?: string;
|
||||
}
|
||||
|
||||
export interface MigrationFile {
|
||||
idx: number;
|
||||
tag: string;
|
||||
sql: string;
|
||||
when: number;
|
||||
}
|
||||
|
||||
export interface MigrationStatus {
|
||||
applied: number;
|
||||
pending: MigrationFile[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface BackupAnalysis {
|
||||
metadata: BackupMetadata;
|
||||
counts: Record<string, number>;
|
||||
boardNames: string[];
|
||||
migrations: MigrationStatus;
|
||||
}
|
||||
|
||||
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
|
||||
|
||||
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
|
||||
import { PREVIEW_TABLE_KEYS } from "./types";
|
||||
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const getAppliedMigrationCount = (db: any): number => {
|
||||
try {
|
||||
const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`);
|
||||
return (result[0]?.values[0]?.[0] as number) ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMigrations = async (): Promise<MigrationFile[]> => {
|
||||
const res = await fetch("/api/backup/migrations");
|
||||
if (!res.ok) throw new Error("Failed to fetch migration files from server");
|
||||
const data = await res.json();
|
||||
return data.migrations ?? [];
|
||||
};
|
||||
|
||||
const countEntities = (db: any): Record<string, number> => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const key of PREVIEW_TABLE_KEYS) {
|
||||
try {
|
||||
const result = db.exec(`SELECT COUNT(*) FROM "${key}"`);
|
||||
counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0;
|
||||
} catch {
|
||||
counts[key] = 0;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const getBoardNames = (db: any): string[] => {
|
||||
try {
|
||||
const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"');
|
||||
return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export interface MigrationProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
tag: string;
|
||||
phase: "pending" | "applying" | "done" | "error";
|
||||
}
|
||||
|
||||
export const useBackupAnalysis = () => {
|
||||
const t = useScopedI18n("management.page.tool.backup.restore");
|
||||
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress | null>(null);
|
||||
const dbRef = useRef<any>(null);
|
||||
|
||||
const analyzeFile = useCallback(async (file: File) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAnalysis(null);
|
||||
setMigrationProgress(null);
|
||||
|
||||
try {
|
||||
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
|
||||
import("jszip").then((m) => m.default),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
import("sql.js").then((m: any) => m.default),
|
||||
fetchMigrations(),
|
||||
fetch("/api/backup/sql-wasm").then((r) => {
|
||||
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
|
||||
return r.arrayBuffer();
|
||||
}),
|
||||
]);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const metadataFile = zip.file("metadata.json");
|
||||
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
|
||||
const dbFile = zip.file("db.sqlite");
|
||||
if (!dbFile) throw new Error(t("invalidMissingDb"));
|
||||
|
||||
const metadata = JSON.parse(await metadataFile.async("string"));
|
||||
const dbBuffer = await dbFile.async("uint8array");
|
||||
|
||||
const SQL = await initSqlJs({ wasmBinary });
|
||||
|
||||
const db = new SQL.Database(dbBuffer);
|
||||
dbRef.current = db;
|
||||
|
||||
try {
|
||||
const appliedCount = getAppliedMigrationCount(db);
|
||||
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
|
||||
|
||||
const migrations: MigrationStatus = {
|
||||
applied: appliedCount,
|
||||
pending: pendingMigrations,
|
||||
total: allMigrations.length,
|
||||
};
|
||||
|
||||
if (pendingMigrations.length > 0) {
|
||||
for (let i = 0; i < pendingMigrations.length; i++) {
|
||||
const migration = pendingMigrations[i]!;
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "applying",
|
||||
});
|
||||
|
||||
await delay(100);
|
||||
|
||||
try {
|
||||
const statements = migration.sql
|
||||
.split("--> statement-breakpoint")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const stmt of statements) {
|
||||
db.run(stmt);
|
||||
}
|
||||
|
||||
db.run(
|
||||
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
|
||||
[migration.tag, Date.now()],
|
||||
);
|
||||
} catch (migrationErr) {
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "error",
|
||||
});
|
||||
throw new Error(
|
||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
setMigrationProgress({
|
||||
current: i + 1,
|
||||
total: pendingMigrations.length,
|
||||
tag: migration.tag,
|
||||
phase: "done",
|
||||
});
|
||||
|
||||
await delay(50);
|
||||
}
|
||||
}
|
||||
|
||||
const counts = countEntities(db);
|
||||
const boardNames = getBoardNames(db);
|
||||
|
||||
setMigrationProgress(null);
|
||||
setAnalysis({ metadata, counts, boardNames, migrations });
|
||||
} finally {
|
||||
db.close();
|
||||
dbRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (dbRef.current) {
|
||||
try { dbRef.current.close(); } catch { /* already closed */ }
|
||||
dbRef.current = null;
|
||||
}
|
||||
setAnalysis(null);
|
||||
setError(null);
|
||||
setMigrationProgress(null);
|
||||
}, []);
|
||||
|
||||
return { analysis, loading, error, migrationProgress, analyzeFile, reset };
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
"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<ConfigImportPreview["compatibility"]["status"], string> = {
|
||||
compatible: "blue",
|
||||
unsupportedVersion: "red",
|
||||
invalidStructure: "red",
|
||||
};
|
||||
|
||||
type CountSection = { labelKey: string; entries: { key: string; count: number }[] };
|
||||
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const buildCountSections = (preview: ConfigImportPreview): CountSection[] => [
|
||||
{
|
||||
labelKey: "management.page.importExport.preview.importWillReuse",
|
||||
entries: Object.entries(preview.toReuse)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => ({ key: `management.page.importExport.preview.reuse${capitalize(key)}`, count })),
|
||||
},
|
||||
{
|
||||
labelKey: "management.page.importExport.preview.importWillSkip",
|
||||
entries: Object.entries(preview.toSkip)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => ({ key: `management.page.importExport.preview.skip${capitalize(key)}`, count })),
|
||||
},
|
||||
{
|
||||
labelKey: "management.page.importExport.preview.importWillUpdate",
|
||||
entries: Object.entries(preview.toUpdate)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => ({ key: `management.page.importExport.preview.update${capitalize(key)}`, count })),
|
||||
},
|
||||
];
|
||||
|
||||
type ConfigImportPreviewPanelProps = {
|
||||
preview: ConfigImportPreview | undefined;
|
||||
previewLoading: boolean;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
export const ConfigImportPreviewPanel = ({ preview, previewLoading, t }: ConfigImportPreviewPanelProps) => {
|
||||
if (!preview && !previewLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const importAllowed = preview?.compatibility.status === "compatible";
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{previewLoading && (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.preview.importLoading")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<Alert
|
||||
variant="light"
|
||||
color={compatibilityAlertColor[preview.compatibility.status]}
|
||||
icon={<IconAlertTriangle size="1rem" stroke={1.5} />}
|
||||
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 && (
|
||||
<Text size="sm" mb="xs">
|
||||
{t("management.page.importExport.preview.importMeta", {
|
||||
exportedAt: preview.meta.exportedAt,
|
||||
homarrVersion: preview.meta.homarrVersion,
|
||||
bundleVersion: preview.meta.bundleVersion,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
{preview.compatibility.issues.length > 0 && (
|
||||
<List size="sm" spacing={4}>
|
||||
{preview.compatibility.issues.map((issue) => (
|
||||
<List.Item key={issue}>{issue}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{importAllowed && (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("management.page.importExport.preview.importWillCreate")}
|
||||
</Text>
|
||||
<ConfigSummaryList counts={preview.toCreate} t={t} />
|
||||
</Stack>
|
||||
|
||||
{buildCountSections(preview)
|
||||
.filter((section) => section.entries.length > 0)
|
||||
.map((section) => (
|
||||
<Stack gap="xs" key={section.labelKey}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t(section.labelKey)}
|
||||
</Text>
|
||||
<List size="sm">
|
||||
{section.entries.map((entry) => (
|
||||
<List.Item key={entry.key}>
|
||||
{t(entry.key, { count: entry.count })}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
{preview.warnings.length > 0 && (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="1rem" />}>
|
||||
<List size="sm">
|
||||
{preview.warnings.map((warning) => (
|
||||
<List.Item key={warning}>{warning}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { List, Text } from "@mantine/core";
|
||||
|
||||
import type { ConfigEntityCounts, ConfigEntityKey } from "@homarr/board-portability";
|
||||
import { CONFIG_ENTITY_KEYS } from "@homarr/board-portability";
|
||||
|
||||
const TRANSLATION_PREFIX = "management.page.importExport.summary";
|
||||
|
||||
type ConfigSummaryListProps = {
|
||||
counts: ConfigEntityCounts;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
|
||||
const entries = CONFIG_ENTITY_KEYS.filter((key) => counts[key] > 0);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`${TRANSLATION_PREFIX}.empty`)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List size="sm" spacing={4}>
|
||||
{entries.map((key: ConfigEntityKey) => (
|
||||
<List.Item key={key}>
|
||||
{t(`${TRANSLATION_PREFIX}.${key}`, { count: counts[key] })}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
Vendored
+1
@@ -2,3 +2,4 @@ declare module "*.png";
|
||||
declare module "*.svg";
|
||||
declare module "*.jpeg";
|
||||
declare module "*.jpg";
|
||||
declare module "sql.js";
|
||||
@@ -1,153 +0,0 @@
|
||||
# Homarr Docker label discovery example.
|
||||
# This compose file demonstrates all supported label combinations for testing.
|
||||
# Prerequisites: Homarr with Docker socket mounted.
|
||||
# In Manage → Tools → Import/Export, use the Docker Discovery panel to scan.
|
||||
|
||||
services:
|
||||
# ─── Full Homarr labels (all fields) ─────────────────────────────────────────
|
||||
sonarr:
|
||||
image: lscr.io/linuxserver/sonarr:latest
|
||||
container_name: homarr-sonarr
|
||||
ports:
|
||||
- "8989:8989"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Sonarr"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://sonarr:8989"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
|
||||
homarr.description: "TV series management"
|
||||
homarr.ping: "http://sonarr:8989/ping"
|
||||
homarr.id: "sonarr-main"
|
||||
homarr.board: "homelab"
|
||||
homarr.integration: "sonarr"
|
||||
homarr.widget: "downloads"
|
||||
|
||||
radarr:
|
||||
image: lscr.io/linuxserver/radarr:latest
|
||||
container_name: homarr-radarr
|
||||
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"
|
||||
|
||||
# ─── Minimal Homarr labels (required fields only) ────────────────────────────
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
container_name: homarr-portainer
|
||||
ports:
|
||||
- "9000:9000"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Portainer"
|
||||
homarr.group: "Infrastructure"
|
||||
homarr.href: "http://portainer:9000"
|
||||
|
||||
# ─── Homepage labels only (migration scenario) ───────────────────────────────
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: homarr-grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homepage.name: "Grafana"
|
||||
homepage.group: "Monitoring"
|
||||
homepage.href: "http://grafana:3000"
|
||||
homepage.icon: "grafana"
|
||||
homepage.description: "Metrics and dashboards"
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: homarr-prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homepage.name: "Prometheus"
|
||||
homepage.group: "Monitoring"
|
||||
homepage.href: "http://prometheus:9090"
|
||||
homepage.icon: "prometheus"
|
||||
|
||||
# ─── Both label sets (homarr takes priority) ─────────────────────────────────
|
||||
qbittorrent:
|
||||
image: lscr.io/linuxserver/qbittorrent:latest
|
||||
container_name: homarr-qbittorrent
|
||||
ports:
|
||||
- "8080:8080"
|
||||
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.name: "qBittorrent (homepage)"
|
||||
homepage.group: "Downloads (homepage)"
|
||||
homepage.href: "http://qbittorrent:8080/homepage"
|
||||
homepage.icon: "qbittorrent"
|
||||
homepage.description: "Should be ignored"
|
||||
|
||||
# ─── Hidden container (excluded from discovery) ──────────────────────────────
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
container_name: homarr-watchtower
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.hide: "true"
|
||||
homarr.name: "Watchtower"
|
||||
homarr.group: "Infrastructure"
|
||||
homarr.href: "http://watchtower:8080"
|
||||
|
||||
# ─── Targeting a specific board ──────────────────────────────────────────────
|
||||
traefik:
|
||||
image: traefik:latest
|
||||
container_name: homarr-traefik
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8081:8080"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Traefik"
|
||||
homarr.group: "Networking"
|
||||
homarr.href: "http://traefik:8080"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg"
|
||||
homarr.board: "networking"
|
||||
homarr.ping: "http://traefik:8080/ping"
|
||||
|
||||
# ─── Custom external ID ──────────────────────────────────────────────────────
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
container_name: homarr-jellyfin
|
||||
ports:
|
||||
- "8096:8096"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Jellyfin"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://jellyfin:8096"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg"
|
||||
homarr.description: "Media streaming"
|
||||
homarr.ping: "http://jellyfin:8096/health"
|
||||
homarr.id: "jellyfin-production"
|
||||
homarr.integration: "jellyfin"
|
||||
homarr.widget: "mediaServer"
|
||||
|
||||
# ─── No discovery labels (should be completely ignored) ──────────────────────
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: homarr-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
+1
-1
@@ -23,7 +23,7 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
client_max_body_size 32M;
|
||||
client_max_body_size 256M;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -78,7 +78,7 @@
|
||||
"overrides": {
|
||||
"@babel/helpers@<7.26.10": ">=7.29.7",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.7",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.17.0",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
||||
"esbuild@<=0.24.2": ">=0.28.0",
|
||||
@@ -88,13 +88,13 @@
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
"proxmox-api>undici": "7.24.6",
|
||||
"react-is": "^19.2.6",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
|
||||
"react-is": "^19.2.7",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
"tmp@<=0.2.3": ">=0.2.7",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.15"
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -28,7 +28,6 @@ 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
|
||||
|
||||
@@ -35,12 +35,6 @@ 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";
|
||||
@@ -49,9 +43,6 @@ import {
|
||||
boardChangeVisibilitySchema,
|
||||
boardCreateSchema,
|
||||
boardDuplicateSchema,
|
||||
boardExportBundleSchema,
|
||||
boardImportBundleSchema,
|
||||
boardNameSchema,
|
||||
boardRenameSchema,
|
||||
boardSaveLayoutsSchema,
|
||||
boardSavePartialSettingsSchema,
|
||||
@@ -63,7 +54,6 @@ 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";
|
||||
@@ -1337,113 +1327,6 @@ 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",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1721,22 +1604,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||
}),
|
||||
),
|
||||
items: items.map(({ integrations: itemIntegrations, ...item }) =>
|
||||
parseItem({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => ({
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
layoutId: layout.layoutId,
|
||||
sectionId: layout.sectionId,
|
||||
})),
|
||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||
}),
|
||||
),
|
||||
items: items
|
||||
.map(({ integrations: itemIntegrations, ...item }) =>
|
||||
parseItem({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => ({
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
layoutId: layout.layoutId,
|
||||
sectionId: layout.sectionId,
|
||||
})),
|
||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||
}),
|
||||
)
|
||||
.filter((item) => item !== null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1752,7 +1637,7 @@ const parseItem = (item: unknown) => {
|
||||
const result = outputItemSchema.safeParse(item);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.message);
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
exportFullConfigAsync,
|
||||
importFullConfigAsync,
|
||||
parseAndValidateBundle,
|
||||
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 }) => {
|
||||
try {
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -3,8 +3,7 @@ import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
|
||||
import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
import {
|
||||
dockerContainersRequestHandler,
|
||||
getContainerLogsAsync,
|
||||
@@ -109,32 +108,6 @@ 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,
|
||||
});
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { importFullConfigAsync, parseAndValidateBundle, previewImportFullConfigAsync } from "@homarr/board-portability";
|
||||
import { onboarding } from "@homarr/db/schema";
|
||||
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
|
||||
import {
|
||||
ensureValidTokenOrThrow,
|
||||
@@ -10,7 +7,6 @@ import {
|
||||
importInitialOldmarrInputSchema,
|
||||
} from "@homarr/old-import/import";
|
||||
|
||||
import packageJson from "../../../../../package.json";
|
||||
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
|
||||
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
|
||||
|
||||
@@ -23,7 +19,12 @@ export const importRouter = createTRPCRouter({
|
||||
}),
|
||||
validateToken: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(z.object({ checksum: z.string(), token: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
checksum: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
ensureValidTokenOrThrow(input.checksum, input.token);
|
||||
@@ -39,28 +40,4 @@ 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 }) => {
|
||||
try {
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
await importFullConfigAsync(ctx.db, bundle, null);
|
||||
|
||||
const hasUsers = (bundle.users ?? []).length > 0;
|
||||
if (hasUsers) {
|
||||
await ctx.db.update(onboarding).set({ previousStep: "import", step: "finish" });
|
||||
} else {
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -50,19 +50,6 @@ vi.mock("@homarr/docker/env", () => ({
|
||||
ENABLE_DOCKER: true,
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/docker", () => ({
|
||||
DockerSingleton: {
|
||||
getInstances: () => [],
|
||||
},
|
||||
listDiscoveredContainersAsync: async () => [],
|
||||
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
|
||||
}));
|
||||
vi.mock("@homarr/db/queries", () => ({
|
||||
getServerSettingsAsync: async () => ({
|
||||
docker: { targetBoardName: null, readHomepageLabels: true, pruneRemoved: false },
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) =>
|
||||
({
|
||||
@@ -86,8 +73,6 @@ const validInputs: {
|
||||
restartAll: { ids: ["1"] },
|
||||
removeAll: { ids: ["1"] },
|
||||
invalidate: undefined,
|
||||
getDiscoveredPreview: undefined,
|
||||
syncDiscovery: undefined,
|
||||
logs: { id: "1", tail: 200 },
|
||||
subscribeLogs: { id: "1", tail: 200 },
|
||||
};
|
||||
|
||||
@@ -52,45 +52,23 @@ export const userRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.email, input.email),
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
|
||||
const userId = existingUser ? existingUser.id : await createUserAsync(ctx.db, input);
|
||||
|
||||
const existingGroup = await ctx.db.query.groups.findFirst({
|
||||
where: eq(groups.name, credentialsAdminGroup),
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
|
||||
let groupId: string;
|
||||
if (existingGroup) {
|
||||
groupId = existingGroup.id;
|
||||
} else {
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
}
|
||||
|
||||
const existingMembership = await ctx.db.query.groupMembers.findFirst({
|
||||
where: and(eq(groupMembers.groupId, groupId), eq(groupMembers.userId, userId)),
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
register: publicProcedure
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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";
|
||||
@@ -1,25 +0,0 @@
|
||||
export {
|
||||
CONFIG_BUNDLE_FORMAT_VERSION,
|
||||
assessBundleCompatibility,
|
||||
parseAndValidateBundle,
|
||||
parseConfigBundleJson,
|
||||
} from "./src/config-bundle-compat";
|
||||
export { USER_DIRECT_FIELDS, USER_REF_FIELDS, GROUP_REF_FIELDS } from "./src/entity-fields";
|
||||
export { resolveEntities, groupByKey, type EntityResolverDef, type ResolveResult } from "./src/resolve-entities";
|
||||
export { homarrBundleSchema, homarrConfigBundleSchema, type HomarrBundle, type HomarrConfigBundle, type ConfigBundleUser } from "./src/schema";
|
||||
export type { ConfigEntityCounts, ConfigEntityKey, ConfigExportPreview, ConfigImportPreview } from "./src/types";
|
||||
export { CONFIG_ENTITY_KEYS, emptyCounts } from "./src/types";
|
||||
export { exportBoardBundleAsync, bundleFilenameForBoard } from "./src/export";
|
||||
export { exportFullConfigAsync } from "./src/export/export-full-config";
|
||||
export { previewExportFullConfigAsync } from "./src/export/preview-export-full-config";
|
||||
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";
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
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> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -1,237 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { HomarrConfigBundle } from "../schema";
|
||||
import { homarrConfigBundleSchema } from "../schema";
|
||||
import { assessBundleCompatibility, parseConfigBundleJson } from "../config-bundle-compat";
|
||||
|
||||
const createMinimalConfigBundle = (overrides: Partial<HomarrConfigBundle> = {}): HomarrConfigBundle => ({
|
||||
version: "2.0",
|
||||
type: "full-config",
|
||||
exportedAt: new Date().toISOString(),
|
||||
homarrVersion: "1.5.0",
|
||||
encryptionKey: "a".repeat(64),
|
||||
boards: [
|
||||
{
|
||||
ref: "board-uuid-1",
|
||||
name: "Main Dashboard",
|
||||
settings: {
|
||||
primaryColor: "#FA5252",
|
||||
secondaryColor: "#868E96",
|
||||
opacity: 100,
|
||||
itemRadius: "md",
|
||||
},
|
||||
layouts: [
|
||||
{ ref: "layout-1", name: "Default", columnCount: 10, breakpoint: 0 },
|
||||
],
|
||||
sections: [
|
||||
{ ref: "section-1", kind: "category", name: "Media", yOffset: 0, xOffset: 0 },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
ref: "item-1",
|
||||
kind: "app",
|
||||
options: { appId: "app-ref-1", openInNewTab: true },
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
integrationRefs: ["int-ref-1"],
|
||||
appRef: "app-ref-1",
|
||||
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 0, y: 0, w: 2, h: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
ref: "app-ref-1",
|
||||
name: "Jellyfin",
|
||||
href: "https://jellyfin.local",
|
||||
iconUrl: "https://cdn.example/jellyfin.png",
|
||||
description: "Media server",
|
||||
pingUrl: null,
|
||||
},
|
||||
],
|
||||
integrations: [
|
||||
{
|
||||
ref: "int-ref-1",
|
||||
kind: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
url: "https://jellyfin.local",
|
||||
secrets: [{ kind: "apiKey", value: "encrypted.value" }],
|
||||
},
|
||||
],
|
||||
serverSettings: {
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
docker: { targetBoardName: null, readHomepageLabels: true },
|
||||
},
|
||||
searchEngines: [
|
||||
{
|
||||
ref: "se-1",
|
||||
iconUrl: "https://google.com/favicon.ico",
|
||||
name: "Google",
|
||||
short: "g",
|
||||
description: "Google search",
|
||||
urlTemplate: "https://google.com/search?q={query}",
|
||||
type: "generic",
|
||||
integrationRef: null,
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
ref: "group-1",
|
||||
name: "Admins",
|
||||
position: 0,
|
||||
ownerRef: "user-1",
|
||||
homeBoardRef: null,
|
||||
mobileHomeBoardRef: null,
|
||||
permissions: ["admin"],
|
||||
boardPermissions: [{ boardRef: "board-uuid-1", permission: "board-full-access" }],
|
||||
integrationPermissions: [{ integrationRef: "int-ref-1", permission: "integration-use-credentials" }],
|
||||
memberRefs: ["user-1"],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
ref: "user-1",
|
||||
name: "admin",
|
||||
email: "admin@example.com",
|
||||
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
|
||||
provider: "credentials",
|
||||
homeBoardRef: "board-uuid-1",
|
||||
mobileHomeBoardRef: null,
|
||||
defaultSearchEngineRef: "se-1",
|
||||
colorScheme: "dark",
|
||||
firstDayOfWeek: 1,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
pingIconsEnabled: false,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("v2.0 config bundle schema", () => {
|
||||
test("schema validates a valid config bundle", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("schema rejects v1.0 version for config bundle", () => {
|
||||
const invalid = { ...createMinimalConfigBundle(), version: "1.0" };
|
||||
const result = homarrConfigBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("schema rejects missing type field", () => {
|
||||
const { type: _type, ...invalid } = createMinimalConfigBundle();
|
||||
const result = homarrConfigBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("schema accepts bundle with no boards", () => {
|
||||
const bundle = createMinimalConfigBundle({ boards: [] });
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("schema accepts board with optional ref field", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
delete (bundle.boards[0] as Record<string, unknown>).ref;
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2.0 config bundle round-trip", () => {
|
||||
test("serialized config bundle parses back correctly", () => {
|
||||
const original = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(original);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
|
||||
expect(parsed.version).toBe("2.0");
|
||||
expect(parsed.type).toBe("full-config");
|
||||
expect(parsed.homarrVersion).toBe("1.5.0");
|
||||
expect(parsed.boards).toHaveLength(1);
|
||||
expect(parsed.boards[0]?.ref).toBe("board-uuid-1");
|
||||
expect(parsed.boards[0]?.name).toBe("Main Dashboard");
|
||||
expect(parsed.apps).toHaveLength(1);
|
||||
expect(parsed.integrations).toHaveLength(1);
|
||||
expect(parsed.integrations[0]?.secrets).toHaveLength(1);
|
||||
expect(parsed.searchEngines).toHaveLength(1);
|
||||
expect(parsed.groups).toHaveLength(1);
|
||||
expect(parsed.groups[0]?.boardPermissions).toHaveLength(1);
|
||||
expect(parsed.groups[0]?.boardPermissions[0]?.boardRef).toBe("board-uuid-1");
|
||||
});
|
||||
|
||||
test("preserves encryption key", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.encryptionKey).toBe("a".repeat(64));
|
||||
});
|
||||
|
||||
test("preserves server settings", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.serverSettings).toEqual({
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
docker: { targetBoardName: null, readHomepageLabels: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves users and group memberships", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
|
||||
expect(parsed.users).toHaveLength(1);
|
||||
expect(parsed.users![0]?.email).toBe("admin@example.com");
|
||||
expect(parsed.users![0]?.provider).toBe("credentials");
|
||||
expect(parsed.users![0]?.homeBoardRef).toBe("board-uuid-1");
|
||||
expect(parsed.groups[0]?.memberRefs).toEqual(["user-1"]);
|
||||
expect(parsed.groups[0]?.ownerRef).toBe("user-1");
|
||||
});
|
||||
|
||||
test("accepts bundle without users (backward compat)", () => {
|
||||
const { users: _users, ...bundleWithoutUsers } = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundleWithoutUsers);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.users).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assessBundleCompatibility", () => {
|
||||
test("marks valid bundle as compatible", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("compatible");
|
||||
expect(result.bundle).not.toBeNull();
|
||||
expect(result.compatibility.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("marks compatible even with version mismatch (different Homarr version)", () => {
|
||||
const bundle = createMinimalConfigBundle({ homarrVersion: "1.4.0" });
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("compatible");
|
||||
expect(result.compatibility.issues).toHaveLength(1);
|
||||
expect(result.compatibility.issues[0]).toContain("1.4.0");
|
||||
});
|
||||
|
||||
test("rejects unsupported format version", () => {
|
||||
const bundle = { ...createMinimalConfigBundle(), version: "3.0" };
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("unsupportedVersion");
|
||||
expect(result.bundle).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects non-object input", () => {
|
||||
const result = assessBundleCompatibility(null, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("invalidStructure");
|
||||
expect(result.bundle).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects malformed structure", () => {
|
||||
const result = assessBundleCompatibility({ version: "2.0", type: "full-config" }, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("invalidStructure");
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
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<string, unknown>;
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const parseAndValidateBundle = (
|
||||
content: string,
|
||||
currentHomarrVersion: string,
|
||||
): HomarrConfigBundle => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
throw new Error(compatibility.issues.join(" "));
|
||||
}
|
||||
|
||||
return bundle;
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Declarative field definitions for entities in the config bundle.
|
||||
*
|
||||
* To add a new user preference:
|
||||
* 1. Add the column to the DB schema (migration)
|
||||
* 2. Add the field name to USER_DIRECT_FIELDS below
|
||||
* That's it — export, import, and schema all derive from this.
|
||||
*
|
||||
* To add a new FK reference on users:
|
||||
* 1. Add the column to the DB schema (migration)
|
||||
* 2. Add an entry to USER_REF_FIELDS below (bundleKey → dbColumn → refMap name)
|
||||
*/
|
||||
|
||||
/**
|
||||
* User fields copied directly between bundle and DB (no transformation).
|
||||
* Adding a new preference here automatically includes it in export AND import.
|
||||
*/
|
||||
export const USER_DIRECT_FIELDS = [
|
||||
"name",
|
||||
"email",
|
||||
"password",
|
||||
"provider",
|
||||
"colorScheme",
|
||||
"firstDayOfWeek",
|
||||
"openSearchInNewTab",
|
||||
"ddgBangs",
|
||||
"pingIconsEnabled",
|
||||
] as const;
|
||||
|
||||
export type UserDirectField = (typeof USER_DIRECT_FIELDS)[number];
|
||||
|
||||
/**
|
||||
* User FK reference fields: bundle uses `xxxRef`, DB uses `xxxId`.
|
||||
* Key = bundle field name (with "Ref" suffix)
|
||||
* Value = which ref map to use during import (matches the entity group name)
|
||||
*/
|
||||
export const USER_REF_FIELDS = {
|
||||
homeBoardRef: "boards",
|
||||
mobileHomeBoardRef: "boards",
|
||||
defaultSearchEngineRef: "searchEngines",
|
||||
} as const;
|
||||
|
||||
export type UserRefField = keyof typeof USER_REF_FIELDS;
|
||||
|
||||
/**
|
||||
* Derives the DB column name from a bundle ref field name.
|
||||
* "homeBoardRef" → "homeBoardId"
|
||||
*/
|
||||
export const refFieldToDbColumn = (refField: string): string =>
|
||||
refField.replace(/Ref$/, "Id");
|
||||
|
||||
/**
|
||||
* Group FK reference fields.
|
||||
*/
|
||||
export const GROUP_REF_FIELDS = {
|
||||
ownerRef: "users",
|
||||
homeBoardRef: "boards",
|
||||
mobileHomeBoardRef: "boards",
|
||||
} as const;
|
||||
@@ -1,215 +0,0 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import type { Database } from "@homarr/db";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
searchEngines,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { CONFIG_BUNDLE_FORMAT_VERSION } from "../config-bundle-compat";
|
||||
import { USER_DIRECT_FIELDS, USER_REF_FIELDS } from "../entity-fields";
|
||||
import { groupByKey } from "../resolve-entities";
|
||||
import type { ConfigBundleUser, HomarrBundleApp, HomarrConfigBundle } from "../schema";
|
||||
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
|
||||
import { loadBoardGraphAsync } from "./load-board-graph";
|
||||
import { buildBoardSettings } from "./serialize-board";
|
||||
|
||||
/**
|
||||
* Exports a user row using USER_DIRECT_FIELDS and USER_REF_FIELDS.
|
||||
* Adding a new preference to USER_DIRECT_FIELDS automatically includes it here.
|
||||
*/
|
||||
const exportUserRow = (user: Record<string, unknown>): ConfigBundleUser => {
|
||||
const bundle: Record<string, unknown> = { ref: user.id };
|
||||
|
||||
for (const field of USER_DIRECT_FIELDS) {
|
||||
bundle[field] = user[field] ?? null;
|
||||
}
|
||||
|
||||
for (const refField of Object.keys(USER_REF_FIELDS)) {
|
||||
const dbCol = refField.replace(/Ref$/, "Id");
|
||||
bundle[refField] = user[dbCol] ?? null;
|
||||
}
|
||||
|
||||
return bundle as unknown as ConfigBundleUser;
|
||||
};
|
||||
|
||||
export const exportFullConfigAsync = async (
|
||||
db: Database,
|
||||
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 allUsers = await db.select().from(users);
|
||||
const allGroupMembers = await db.select().from(groupMembers);
|
||||
|
||||
const secretsByIntegration = groupByKey(allSecrets, (s) => s.integrationId);
|
||||
|
||||
const bundleIntegrations = allIntegrations.map((integration) => ({
|
||||
ref: integration.id,
|
||||
kind: integration.kind,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
secrets: (secretsByIntegration.get(integration.id) ?? []).map((s) => ({
|
||||
kind: s.kind as IntegrationSecretKind,
|
||||
value: s.value,
|
||||
})),
|
||||
}));
|
||||
|
||||
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<Record<string, unknown>>(item.options, {});
|
||||
const advancedOptions = itemAdvancedOptionsSchema.parse(
|
||||
parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})),
|
||||
);
|
||||
const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record<string, unknown>;
|
||||
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({
|
||||
ref: boardRow.id,
|
||||
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<string, unknown>) : 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<string, unknown> = {};
|
||||
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 = groupByKey(allGroupPermissions, (gp) => gp.groupId);
|
||||
const boardPermsByGroup = groupByKey(allBoardGroupPermissions, (bp) => bp.groupId);
|
||||
const integrationPermsByGroup = groupByKey(allIntegrationGroupPermissions, (ip) => ip.groupId);
|
||||
const membersByGroup = groupByKey(allGroupMembers, (gm) => gm.groupId);
|
||||
|
||||
const bundleGroups = allGroups.map((group) => ({
|
||||
ref: group.id,
|
||||
name: group.name,
|
||||
position: group.position,
|
||||
ownerRef: group.ownerId ?? null,
|
||||
homeBoardRef: group.homeBoardId ?? null,
|
||||
mobileHomeBoardRef: group.mobileHomeBoardId ?? null,
|
||||
permissions: (permissionsByGroup.get(group.id) ?? []).map((gp) => gp.permission),
|
||||
boardPermissions: (boardPermsByGroup.get(group.id) ?? []).map((bp) => ({
|
||||
boardRef: bp.boardId,
|
||||
permission: bp.permission,
|
||||
})),
|
||||
integrationPermissions: (integrationPermsByGroup.get(group.id) ?? []).map((ip) => ({
|
||||
integrationRef: ip.integrationId,
|
||||
permission: ip.permission,
|
||||
})),
|
||||
memberRefs: (membersByGroup.get(group.id) ?? []).map((gm) => gm.userId),
|
||||
}));
|
||||
|
||||
const bundleUsers = allUsers.map((user) => exportUserRow(user as unknown as Record<string, unknown>));
|
||||
|
||||
const bundle: HomarrConfigBundle = {
|
||||
version: CONFIG_BUNDLE_FORMAT_VERSION,
|
||||
type: "full-config",
|
||||
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,
|
||||
users: bundleUsers,
|
||||
};
|
||||
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
return {
|
||||
content: JSON.stringify(bundle, null, 2),
|
||||
filename: `homarr-config-${now}.json`,
|
||||
};
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
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";
|
||||
@@ -1,35 +0,0 @@
|
||||
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<Awaited<ReturnType<typeof loadBoardGraphAsync>>>;
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
import {
|
||||
groups,
|
||||
integrationSecrets,
|
||||
integrations,
|
||||
items,
|
||||
layouts,
|
||||
searchEngines,
|
||||
sections,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
|
||||
import type { ConfigExportPreview } from "../types";
|
||||
|
||||
export const previewExportFullConfigAsync = async (db: Database): Promise<ConfigExportPreview> => {
|
||||
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 allUsers = await db.select().from(users);
|
||||
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,
|
||||
users: allUsers.length,
|
||||
serverSettings: allServerSettings.length,
|
||||
};
|
||||
};
|
||||
@@ -1,168 +0,0 @@
|
||||
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";
|
||||
|
||||
export 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<HomarrBundle> => {
|
||||
const appIdCandidates = new Set<string>();
|
||||
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<Record<string, unknown>>(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<Record<string, unknown>>(item.options, {});
|
||||
const advancedOptions = itemAdvancedOptionsSchema.parse(
|
||||
parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})),
|
||||
);
|
||||
const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record<string, unknown>;
|
||||
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<string, unknown>) : 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,
|
||||
};
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
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`;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
const ENV_VAR_PATTERN = /\{\{(HOMEPAGE_VAR_[A-Z0-9_]+)\}\}/g;
|
||||
|
||||
export const extractHomepageEnvVariables = (content: string): string[] => {
|
||||
const matches = new Set<string>();
|
||||
for (const match of content.matchAll(ENV_VAR_PATTERN)) {
|
||||
matches.add(match[1] ?? "");
|
||||
}
|
||||
return [...matches].sort();
|
||||
};
|
||||
|
||||
export const replaceHomepageEnvVariables = (
|
||||
content: string,
|
||||
values: Record<string, string>,
|
||||
): string =>
|
||||
content.replaceAll(ENV_VAR_PATTERN, (full, varName: string) => values[varName] ?? full);
|
||||
@@ -1,76 +0,0 @@
|
||||
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<HomepageImportReport> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export { parseServicesYaml } from "./parse-services-yaml";
|
||||
export { homepageWidgetMap, type HomepageWidgetMapping } from "@homarr/definitions";
|
||||
export { prepareHomepageImport, resolveHomepageIconUrl, type PreparedHomepageImport } from "./prepare-homepage-import";
|
||||
export { importHomepageServicesAsync, type HomepageImportReport } from "./import";
|
||||
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
|
||||
@@ -1,111 +0,0 @@
|
||||
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", fields: { 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" });
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
|
||||
|
||||
const extractWidgets = (config: Record<string, unknown>): 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<string, unknown> & { type: string } => {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
"type" in value &&
|
||||
typeof value.type === "string"
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeWidget = (widget: Record<string, unknown>): HomepageWidget => {
|
||||
const { type, url, key, ...rest } = widget;
|
||||
return {
|
||||
type: String(type),
|
||||
...(typeof url === "string" ? { url } : {}),
|
||||
...(typeof key === "string" ? { key } : {}),
|
||||
fields: rest,
|
||||
};
|
||||
};
|
||||
|
||||
const isServiceConfig = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const parseService = (group: string, name: string, config: Record<string, unknown>): 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, "") };
|
||||
};
|
||||
@@ -1,346 +0,0 @@
|
||||
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, homepageWidgetMap, integrationDefs } from "@homarr/definitions";
|
||||
|
||||
import { stringifyForDb } from "../utils";
|
||||
import type { HomepageService, HomepageWidget } from "./types";
|
||||
|
||||
const DASHBOARD_ICON_CDN = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg";
|
||||
const ENV_VAR_PATTERN = /^\$\{[^}]+\}$/;
|
||||
const COLUMN_COUNT = 10;
|
||||
const WIDGET_WIDTH = 2;
|
||||
|
||||
export type PreparedHomepageImport = {
|
||||
apps: InferInsertModel<typeof apps>[];
|
||||
integrations: InferInsertModel<typeof integrations>[];
|
||||
integrationSecrets: InferInsertModel<typeof integrationSecrets>[];
|
||||
board: InferInsertModel<typeof boards>;
|
||||
layouts: InferInsertModel<typeof layouts>[];
|
||||
sections: InferInsertModel<typeof sections>[];
|
||||
items: InferInsertModel<typeof items>[];
|
||||
itemLayouts: InferInsertModel<typeof itemLayouts>[];
|
||||
integrationItems: InferInsertModel<typeof integrationItems>[];
|
||||
warnings: string[];
|
||||
unmappedWidgetTypes: string[];
|
||||
};
|
||||
|
||||
type GridPosition = { x: number; y: number };
|
||||
|
||||
const advanceGrid = (grid: GridPosition, width: number): GridPosition => {
|
||||
const nextX = grid.x + width;
|
||||
if (nextX >= COLUMN_COUNT) {
|
||||
return { x: 0, y: grid.y + 1 };
|
||||
}
|
||||
return { x: nextX, y: grid.y };
|
||||
};
|
||||
|
||||
const isEnvVarReference = (value: string) => ENV_VAR_PATTERN.test(value.trim());
|
||||
|
||||
export const resolveHomepageIconUrl = (icon: string | undefined, serviceName: string): string => {
|
||||
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.fields[homepageField] ?? (homepageField === "key" ? widget.key : undefined);
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
};
|
||||
|
||||
const descriptionDisplayModes: Record<string, string> = {
|
||||
present: "tooltip",
|
||||
absent: "hidden",
|
||||
};
|
||||
|
||||
type WidgetProcessingContext = {
|
||||
service: HomepageService;
|
||||
appId: string;
|
||||
sectionId: string;
|
||||
boardId: string;
|
||||
layoutId: string;
|
||||
gridState: Map<string, GridPosition>;
|
||||
integrationRows: InferInsertModel<typeof integrations>[];
|
||||
integrationSecretRows: InferInsertModel<typeof integrationSecrets>[];
|
||||
itemRows: InferInsertModel<typeof items>[];
|
||||
itemLayoutRows: InferInsertModel<typeof itemLayouts>[];
|
||||
integrationItemRows: InferInsertModel<typeof integrationItems>[];
|
||||
warnings: string[];
|
||||
unmappedWidgetTypesSet: Set<string>;
|
||||
};
|
||||
|
||||
const processServiceWidgets = (ctx: WidgetProcessingContext) => {
|
||||
for (const widget of ctx.service.widgets) {
|
||||
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
|
||||
if (!mapping) {
|
||||
ctx.unmappedWidgetTypesSet.add(widget.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
const integrationId = createId();
|
||||
const integrationUrl =
|
||||
(typeof widget.url === "string" && widget.url.length > 0 ? widget.url : ctx.service.href) ?? "";
|
||||
|
||||
if (integrationUrl.length === 0) {
|
||||
ctx.warnings.push(`Skipped integration for "${ctx.service.name}" (${widget.type}): missing URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.integrationRows.push({
|
||||
id: integrationId,
|
||||
kind: mapping.integrationKind,
|
||||
name: ctx.service.name,
|
||||
url: integrationUrl,
|
||||
appId: ctx.appId,
|
||||
});
|
||||
|
||||
processSecrets(ctx, widget, mapping, integrationId);
|
||||
validateSecrets(ctx, mapping, integrationId);
|
||||
placeWidgetItem(ctx, mapping.widgetKind, integrationId);
|
||||
}
|
||||
};
|
||||
|
||||
const processSecrets = (
|
||||
ctx: WidgetProcessingContext,
|
||||
widget: HomepageWidget,
|
||||
mapping: { secretFieldMap: Record<string, IntegrationSecretKind>; integrationKind: string },
|
||||
integrationId: string,
|
||||
) => {
|
||||
for (const [homepageField, secretKind] of Object.entries(mapping.secretFieldMap)) {
|
||||
const rawValue = extractSecretValue(widget, homepageField);
|
||||
if (!rawValue) continue;
|
||||
|
||||
if (isEnvVarReference(rawValue)) {
|
||||
ctx.warnings.push(
|
||||
`Unresolved secret for "${ctx.service.name}" (${widget.type}): ${homepageField} references an environment variable`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.integrationSecretRows.push({
|
||||
integrationId,
|
||||
kind: secretKind as IntegrationSecretKind,
|
||||
value: encryptSecret(rawValue),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateSecrets = (
|
||||
ctx: WidgetProcessingContext,
|
||||
mapping: { integrationKind: string },
|
||||
integrationId: string,
|
||||
) => {
|
||||
const requiredSecretSets = integrationDefs[mapping.integrationKind as keyof typeof integrationDefs].secretKinds;
|
||||
const providedKinds = new Set(
|
||||
ctx.integrationSecretRows.filter((s) => s.integrationId === integrationId).map((s) => s.kind),
|
||||
);
|
||||
const hasRequiredSecrets = requiredSecretSets.some(
|
||||
(secretSet) => secretSet.length === 0 || secretSet.every((kind) => providedKinds.has(kind)),
|
||||
);
|
||||
|
||||
if (!hasRequiredSecrets && requiredSecretSets.every((set) => set.length > 0)) {
|
||||
ctx.warnings.push(`Integration "${ctx.service.name}" (${mapping.integrationKind}) may be missing required credentials`);
|
||||
}
|
||||
};
|
||||
|
||||
const placeWidgetItem = (ctx: WidgetProcessingContext, widgetKind: string, integrationId: string) => {
|
||||
const widgetItemId = createId();
|
||||
const grid = ctx.gridState.get(ctx.sectionId) ?? { x: 0, y: 0 };
|
||||
|
||||
ctx.itemRows.push({
|
||||
id: widgetItemId,
|
||||
boardId: ctx.boardId,
|
||||
kind: widgetKind as never,
|
||||
options: emptySuperJSON,
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
ctx.itemLayoutRows.push({
|
||||
itemId: widgetItemId,
|
||||
sectionId: ctx.sectionId,
|
||||
layoutId: ctx.layoutId,
|
||||
width: WIDGET_WIDTH,
|
||||
height: 1,
|
||||
xOffset: grid.x,
|
||||
yOffset: grid.y,
|
||||
});
|
||||
|
||||
ctx.gridState.set(ctx.sectionId, advanceGrid(grid, WIDGET_WIDTH));
|
||||
|
||||
ctx.integrationItemRows.push({
|
||||
itemId: widgetItemId,
|
||||
integrationId,
|
||||
});
|
||||
};
|
||||
|
||||
export const prepareHomepageImport = (
|
||||
services: HomepageService[],
|
||||
boardName: string,
|
||||
creatorId: string,
|
||||
createIntegrations: boolean,
|
||||
): PreparedHomepageImport => {
|
||||
const warnings: string[] = [];
|
||||
const unmappedWidgetTypesSet = new Set<string>();
|
||||
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
|
||||
const board: InferInsertModel<typeof boards> = {
|
||||
id: boardId,
|
||||
name: boardName,
|
||||
creatorId,
|
||||
isPublic: false,
|
||||
};
|
||||
|
||||
const layoutRows: InferInsertModel<typeof layouts>[] = [
|
||||
{ id: layoutId, boardId, name: "Base", columnCount: COLUMN_COUNT, breakpoint: 0 },
|
||||
];
|
||||
|
||||
const groupNames = [...new Set(services.map((service) => service.group || "Services"))];
|
||||
const sectionByGroup = new Map<string, string>();
|
||||
const sectionRows: InferInsertModel<typeof sections>[] = groupNames.map((groupName, index) => {
|
||||
const sectionId = createId();
|
||||
sectionByGroup.set(groupName, sectionId);
|
||||
return {
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "category" as const,
|
||||
name: groupName || "Services",
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
};
|
||||
});
|
||||
|
||||
const appRows: InferInsertModel<typeof apps>[] = [];
|
||||
const integrationRows: InferInsertModel<typeof integrations>[] = [];
|
||||
const integrationSecretRows: InferInsertModel<typeof integrationSecrets>[] = [];
|
||||
const itemRows: InferInsertModel<typeof items>[] = [];
|
||||
const itemLayoutRows: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const integrationItemRows: InferInsertModel<typeof integrationItems>[] = [];
|
||||
const gridState = new Map<string, GridPosition>();
|
||||
|
||||
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: descriptionDisplayModes[service.description ? "present" : "absent"],
|
||||
pingEnabled: pingUrl !== null,
|
||||
}),
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
itemLayoutRows.push({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
width: 1,
|
||||
height: 1,
|
||||
xOffset: grid.x,
|
||||
yOffset: grid.y,
|
||||
});
|
||||
|
||||
gridState.set(sectionId, advanceGrid(grid, 1));
|
||||
|
||||
if (!createIntegrations) {
|
||||
for (const widget of service.widgets) {
|
||||
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
|
||||
if (!mapping) {
|
||||
unmappedWidgetTypesSet.add(widget.type);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
processServiceWidgets({
|
||||
service,
|
||||
appId,
|
||||
sectionId,
|
||||
boardId,
|
||||
layoutId,
|
||||
gridState,
|
||||
integrationRows,
|
||||
integrationSecretRows,
|
||||
itemRows,
|
||||
itemLayoutRows,
|
||||
integrationItemRows,
|
||||
warnings,
|
||||
unmappedWidgetTypesSet,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apps: appRows,
|
||||
integrations: integrationRows,
|
||||
integrationSecrets: integrationSecretRows,
|
||||
board,
|
||||
layouts: layoutRows,
|
||||
sections: sectionRows,
|
||||
items: itemRows,
|
||||
itemLayouts: itemLayoutRows,
|
||||
integrationItems: integrationItemRows,
|
||||
warnings,
|
||||
unmappedWidgetTypes: [...unmappedWidgetTypesSet].sort(),
|
||||
};
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
fields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ParseServicesYamlResult =
|
||||
| { success: true; services: HomepageService[] }
|
||||
| { success: false; error: string };
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { homepageWidgetMap, integrationDefs, integrationKinds } from "@homarr/definitions";
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,506 +0,0 @@
|
||||
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 {
|
||||
boardGroupPermissions,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
searchEngines,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { apps } from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
|
||||
import { GROUP_REF_FIELDS, USER_DIRECT_FIELDS, USER_REF_FIELDS, refFieldToDbColumn } from "../entity-fields";
|
||||
import { resolveEntities } from "../resolve-entities";
|
||||
import type { HomarrBundleBoard, HomarrConfigBundle } from "../schema";
|
||||
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
|
||||
import { loadExistingEntitiesAsync } from "./load-existing-entities";
|
||||
|
||||
export type ConfigImportReport = {
|
||||
boards: number;
|
||||
apps: number;
|
||||
integrations: number;
|
||||
groups: number;
|
||||
users: number;
|
||||
searchEngines: number;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const reEncryptSecret = (encryptedValue: string, sourceKeyHex: string): `${string}.${string}` => {
|
||||
const sourceKey = Buffer.from(sourceKeyHex, "hex");
|
||||
const decrypted = decryptSecretWithKey(encryptedValue as `${string}.${string}`, sourceKey);
|
||||
return encryptSecret(decrypted);
|
||||
};
|
||||
|
||||
const resolveRef = (map: Map<string, string>, ref: string | null | undefined): string | null => {
|
||||
if (!ref) return null;
|
||||
return map.get(ref) ?? null;
|
||||
};
|
||||
|
||||
const resolveApps = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingApps: { id: string; name: string; iconUrl: string; href: string | null }[],
|
||||
) =>
|
||||
resolveEntities(bundle.apps, existingApps, {
|
||||
getRef: (app) => app.ref,
|
||||
match: (bundle, existing) =>
|
||||
existing.name === bundle.name && existing.iconUrl === bundle.iconUrl && existing.href === bundle.href,
|
||||
toInsertRow: (app, id): (typeof apps.$inferInsert) => ({
|
||||
id,
|
||||
name: app.name,
|
||||
iconUrl: app.iconUrl,
|
||||
href: app.href,
|
||||
description: app.description ?? null,
|
||||
pingUrl: app.pingUrl ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
const resolveIntegrations = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingIntegrations: { id: string; kind: string; name: string; url: string }[],
|
||||
) => {
|
||||
const result = resolveEntities(bundle.integrations, existingIntegrations, {
|
||||
getRef: (i) => i.ref,
|
||||
match: (bundle, existing) =>
|
||||
existing.kind === bundle.kind && existing.name === bundle.name && existing.url === bundle.url,
|
||||
toInsertRow: (integration, id): (typeof integrations.$inferInsert) => ({
|
||||
id,
|
||||
kind: integration.kind,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
}),
|
||||
});
|
||||
|
||||
const secretRows: (typeof integrationSecrets.$inferInsert)[] = [];
|
||||
const secretWarnings: string[] = [];
|
||||
const sourceKey = bundle.encryptionKey;
|
||||
|
||||
for (const bundleIntegration of bundle.integrations) {
|
||||
const newId = result.refToId.get(bundleIntegration.ref);
|
||||
if (!newId || existingIntegrations.some((e) => e.id === newId)) continue;
|
||||
|
||||
for (const secret of bundleIntegration.secrets) {
|
||||
try {
|
||||
const reEncrypted = reEncryptSecret(secret.value, sourceKey);
|
||||
secretRows.push({
|
||||
integrationId: newId,
|
||||
kind: secret.kind as IntegrationSecretKind,
|
||||
value: reEncrypted,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} catch {
|
||||
secretWarnings.push(`Failed to re-encrypt secret "${secret.kind}" for integration "${bundleIntegration.name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
secretRows,
|
||||
warnings: [...result.warnings, ...secretWarnings],
|
||||
};
|
||||
};
|
||||
|
||||
const resolveBoards = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingBoards: { id: string; name: string }[],
|
||||
) => {
|
||||
const boardRefToId = new Map<string, string>();
|
||||
const boardsToCreate = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
const existingBoardNames = new Set(existingBoards.map((b) => b.name));
|
||||
|
||||
for (const bundleBoard of bundle.boards) {
|
||||
if (existingBoardNames.has(bundleBoard.name)) {
|
||||
const existing = existingBoards.find((b) => b.name === bundleBoard.name);
|
||||
if (existing) {
|
||||
boardRefToId.set(bundleBoard.name, existing.id);
|
||||
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, existing.id);
|
||||
}
|
||||
warnings.push(`Board "${bundleBoard.name}" already exists and was skipped`);
|
||||
continue;
|
||||
}
|
||||
const boardId = createId();
|
||||
boardRefToId.set(bundleBoard.name, boardId);
|
||||
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, boardId);
|
||||
boardsToCreate.add(bundleBoard.name);
|
||||
}
|
||||
|
||||
return { boardRefToId, boardsToCreate, warnings };
|
||||
};
|
||||
|
||||
const resolveSearchEngines = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingSearchEngines: { id: string; short: string }[],
|
||||
integrationRefToId: Map<string, string>,
|
||||
) => {
|
||||
const result = resolveEntities(bundle.searchEngines, existingSearchEngines, {
|
||||
getRef: (se) => se.ref,
|
||||
match: (bundle, existing) => existing.short === bundle.short,
|
||||
toInsertRow: (se, id): (typeof searchEngines.$inferInsert) => ({
|
||||
id,
|
||||
iconUrl: se.iconUrl,
|
||||
name: se.name,
|
||||
short: se.short,
|
||||
description: se.description ?? null,
|
||||
urlTemplate: se.urlTemplate ?? null,
|
||||
type: se.type as never,
|
||||
integrationId: resolveRef(integrationRefToId, se.integrationRef),
|
||||
}),
|
||||
skipMessage: (se) => `Search engine "${se.name}" (short: ${se.short}) already exists and was skipped`,
|
||||
});
|
||||
|
||||
for (const existing of existingSearchEngines) {
|
||||
result.refToId.set(existing.id, existing.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses USER_DIRECT_FIELDS and USER_REF_FIELDS to build user insert rows.
|
||||
* Adding a new user preference to USER_DIRECT_FIELDS automatically
|
||||
* includes it here — no other changes needed.
|
||||
*/
|
||||
const resolveUsers = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingUsers: { id: string; email: string | null }[],
|
||||
refMaps: Record<string, Map<string, string>>,
|
||||
) =>
|
||||
resolveEntities(bundle.users ?? [], existingUsers, {
|
||||
getRef: (u) => u.ref,
|
||||
match: (bundle, existing) => Boolean(bundle.email) && existing.email === bundle.email,
|
||||
toInsertRow: (bundleUser, id): (typeof users.$inferInsert) => {
|
||||
const row: Record<string, unknown> = { id };
|
||||
|
||||
for (const field of USER_DIRECT_FIELDS) {
|
||||
row[field] = (bundleUser as Record<string, unknown>)[field] ?? null;
|
||||
}
|
||||
|
||||
for (const [refField, mapName] of Object.entries(USER_REF_FIELDS)) {
|
||||
const dbCol = refFieldToDbColumn(refField);
|
||||
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleUser as Record<string, unknown>)[refField] as string);
|
||||
}
|
||||
|
||||
return row as typeof users.$inferInsert;
|
||||
},
|
||||
});
|
||||
|
||||
const resolveGroups = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingGroups: { id: string; name: string }[],
|
||||
existingMembershipKeys: Set<string>,
|
||||
refMaps: Record<string, Map<string, string>>,
|
||||
) => {
|
||||
const result = resolveEntities(bundle.groups, existingGroups, {
|
||||
getRef: (g) => g.ref,
|
||||
match: (bundle, existing) => existing.name === bundle.name,
|
||||
toInsertRow: (bundleGroup, id): (typeof groups.$inferInsert) => {
|
||||
const row: Record<string, unknown> = { id, name: bundleGroup.name, position: bundleGroup.position };
|
||||
|
||||
for (const [refField, mapName] of Object.entries(GROUP_REF_FIELDS)) {
|
||||
const dbCol = refFieldToDbColumn(refField);
|
||||
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleGroup as Record<string, unknown>)[refField] as string);
|
||||
}
|
||||
|
||||
return row as typeof groups.$inferInsert;
|
||||
},
|
||||
skipMessage: (g) => `Group "${g.name}" already exists and was skipped`,
|
||||
});
|
||||
|
||||
const groupPermRows: (typeof groupPermissions.$inferInsert)[] = [];
|
||||
const boardGroupPermRows: (typeof boardGroupPermissions.$inferInsert)[] = [];
|
||||
const integrationGroupPermRows: (typeof integrationGroupPermissions.$inferInsert)[] = [];
|
||||
const memberRows: (typeof groupMembers.$inferInsert)[] = [];
|
||||
const extraWarnings: string[] = [];
|
||||
|
||||
for (const bundleGroup of bundle.groups) {
|
||||
const groupId = result.refToId.get(bundleGroup.ref);
|
||||
if (!groupId || existingGroups.some((g) => g.id === groupId)) continue;
|
||||
|
||||
for (const perm of bundleGroup.permissions) {
|
||||
groupPermRows.push({ groupId, permission: perm as never });
|
||||
}
|
||||
}
|
||||
|
||||
for (const bundleGroup of bundle.groups) {
|
||||
const groupId = result.refToId.get(bundleGroup.ref);
|
||||
if (!groupId) continue;
|
||||
|
||||
for (const bp of bundleGroup.boardPermissions) {
|
||||
const resolvedBoardId = resolveRef(refMaps.boards!, bp.boardRef);
|
||||
if (!resolvedBoardId) {
|
||||
extraWarnings.push(`Board permission for group "${bundleGroup.name}" references unknown board, skipped`);
|
||||
continue;
|
||||
}
|
||||
boardGroupPermRows.push({ groupId, boardId: resolvedBoardId, permission: bp.permission as never });
|
||||
}
|
||||
|
||||
for (const ip of bundleGroup.integrationPermissions) {
|
||||
const resolvedIntegrationId = resolveRef(refMaps.integrations!, ip.integrationRef);
|
||||
if (resolvedIntegrationId) {
|
||||
integrationGroupPermRows.push({ groupId, integrationId: resolvedIntegrationId, permission: ip.permission as never });
|
||||
}
|
||||
}
|
||||
|
||||
for (const memberRef of bundleGroup.memberRefs ?? []) {
|
||||
const userId = refMaps.users!.get(memberRef);
|
||||
if (!userId) continue;
|
||||
const key = `${groupId}:${userId}`;
|
||||
if (existingMembershipKeys.has(key)) continue;
|
||||
memberRows.push({ groupId, userId });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
groupPermRows,
|
||||
boardGroupPermRows,
|
||||
integrationGroupPermRows,
|
||||
memberRows,
|
||||
warnings: [...result.warnings, ...extraWarnings],
|
||||
};
|
||||
};
|
||||
|
||||
const resolveServerSettings = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingSettings: { settingKey: string; value: string }[],
|
||||
) => {
|
||||
const existingKeys = new Set(existingSettings.map((s) => s.settingKey));
|
||||
const toInsert: (typeof serverSettings.$inferInsert)[] = [];
|
||||
const toUpdate: { key: string; value: string }[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(bundle.serverSettings)) {
|
||||
const serialized = SuperJSON.stringify(value);
|
||||
if (existingKeys.has(key)) {
|
||||
toUpdate.push({ key, value: serialized });
|
||||
} else {
|
||||
toInsert.push({ settingKey: key, value: serialized });
|
||||
}
|
||||
}
|
||||
|
||||
return { toInsert, toUpdate };
|
||||
};
|
||||
|
||||
const buildBoardInserts = (
|
||||
bundle: HomarrConfigBundle,
|
||||
boardsToCreate: Set<string>,
|
||||
boardRefToId: Map<string, string>,
|
||||
appRefToId: Map<string, string>,
|
||||
integrationRefToId: Map<string, string>,
|
||||
creatorId: string | null,
|
||||
insertCollection: Pick<ReturnType<typeof createDbInsertCollectionForTransaction>, "boards" | "layouts" | "sections" | "sectionLayouts" | "items" | "itemLayouts" | "integrationItems">,
|
||||
) => {
|
||||
let boardsCreated = 0;
|
||||
|
||||
for (const bundleBoard of bundle.boards) {
|
||||
if (!boardsToCreate.has(bundleBoard.name)) continue;
|
||||
const boardId = boardRefToId.get(bundleBoard.name);
|
||||
if (!boardId) continue;
|
||||
|
||||
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(buildBoardInsertRow(boardId, sourceBoard.name, creatorId, sourceBoard.settings));
|
||||
|
||||
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<string, unknown>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return boardsCreated;
|
||||
};
|
||||
|
||||
export const importFullConfigAsync = async (
|
||||
db: Database,
|
||||
bundle: HomarrConfigBundle,
|
||||
creatorId: string | null,
|
||||
): Promise<ConfigImportReport> => {
|
||||
const existing = await loadExistingEntitiesAsync(db);
|
||||
const existingMembershipKeys = new Set(existing.groupMembers.map((gm) => `${gm.groupId}:${gm.userId}`));
|
||||
|
||||
const resolvedApps = resolveApps(bundle, existing.apps);
|
||||
const resolvedIntegrations = resolveIntegrations(bundle, existing.integrations);
|
||||
const resolvedBoardsResult = resolveBoards(bundle, existing.boards);
|
||||
const resolvedSearchEngines = resolveSearchEngines(bundle, existing.searchEngines, resolvedIntegrations.refToId);
|
||||
|
||||
const refMaps: Record<string, Map<string, string>> = {
|
||||
boards: resolvedBoardsResult.boardRefToId,
|
||||
searchEngines: resolvedSearchEngines.refToId,
|
||||
integrations: resolvedIntegrations.refToId,
|
||||
users: new Map<string, string>(),
|
||||
};
|
||||
|
||||
const resolvedUsers = resolveUsers(bundle, existing.users, refMaps);
|
||||
refMaps.users = resolvedUsers.refToId;
|
||||
|
||||
const resolvedGroups = resolveGroups(bundle, existing.groups, existingMembershipKeys, refMaps);
|
||||
|
||||
const resolvedSettings = resolveServerSettings(bundle, existing.serverSettings);
|
||||
|
||||
const insertCollection = createDbInsertCollectionForTransaction([
|
||||
"apps",
|
||||
"integrations",
|
||||
"integrationSecrets",
|
||||
"boards",
|
||||
"layouts",
|
||||
"sections",
|
||||
"sectionLayouts",
|
||||
"items",
|
||||
"itemLayouts",
|
||||
"integrationItems",
|
||||
"searchEngines",
|
||||
"users",
|
||||
"groups",
|
||||
"groupPermissions",
|
||||
"groupMembers",
|
||||
"boardGroupPermissions",
|
||||
"integrationGroupPermissions",
|
||||
]);
|
||||
|
||||
insertCollection.apps.push(...resolvedApps.rows);
|
||||
insertCollection.integrations.push(...resolvedIntegrations.rows);
|
||||
insertCollection.integrationSecrets.push(...resolvedIntegrations.secretRows);
|
||||
insertCollection.searchEngines.push(...resolvedSearchEngines.rows);
|
||||
insertCollection.users.push(...resolvedUsers.rows);
|
||||
insertCollection.groups.push(...resolvedGroups.rows);
|
||||
insertCollection.groupPermissions.push(...resolvedGroups.groupPermRows);
|
||||
insertCollection.groupMembers.push(...resolvedGroups.memberRows);
|
||||
insertCollection.boardGroupPermissions.push(...resolvedGroups.boardGroupPermRows);
|
||||
insertCollection.integrationGroupPermissions.push(...resolvedGroups.integrationGroupPermRows);
|
||||
|
||||
const boardsCreated = buildBoardInserts(
|
||||
bundle,
|
||||
resolvedBoardsResult.boardsToCreate,
|
||||
resolvedBoardsResult.boardRefToId,
|
||||
resolvedApps.refToId,
|
||||
resolvedIntegrations.refToId,
|
||||
creatorId,
|
||||
insertCollection,
|
||||
);
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(innerDb, dbSchema) {
|
||||
await innerDb.transaction(async (trx) => {
|
||||
await insertCollection.insertAllAsync(trx);
|
||||
if (resolvedSettings.toInsert.length > 0) {
|
||||
await trx.insert(dbSchema.serverSettings).values(resolvedSettings.toInsert as never);
|
||||
}
|
||||
for (const setting of resolvedSettings.toUpdate) {
|
||||
await trx
|
||||
.update(dbSchema.serverSettings)
|
||||
.set({ value: setting.value })
|
||||
.where(eq(dbSchema.serverSettings.settingKey, setting.key));
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(innerDb) {
|
||||
innerDb.transaction((trx) => {
|
||||
insertCollection.insertAll(trx);
|
||||
if (resolvedSettings.toInsert.length > 0) {
|
||||
trx.insert(serverSettings).values(resolvedSettings.toInsert as never).run();
|
||||
}
|
||||
for (const setting of resolvedSettings.toUpdate) {
|
||||
trx
|
||||
.update(serverSettings)
|
||||
.set({ value: setting.value })
|
||||
.where(eq(serverSettings.settingKey, setting.key))
|
||||
.run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const warnings = [
|
||||
...resolvedIntegrations.warnings,
|
||||
...resolvedBoardsResult.warnings,
|
||||
...resolvedSearchEngines.warnings,
|
||||
...resolvedGroups.warnings,
|
||||
];
|
||||
|
||||
return {
|
||||
boards: boardsCreated,
|
||||
apps: resolvedApps.rows.length,
|
||||
integrations: resolvedIntegrations.rows.length,
|
||||
groups: resolvedGroups.rows.length,
|
||||
users: resolvedUsers.rows.length,
|
||||
searchEngines: resolvedSearchEngines.rows.length,
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export { parseBundleJson } from "./parse-bundle";
|
||||
export { prepareBoardImport, type PreparedBundleImport } from "./prepare-board";
|
||||
export { importBoardBundleAsync, type BundleImportReport } from "./insert";
|
||||
export { doAppsMatch } from "./match-apps";
|
||||
@@ -1,79 +0,0 @@
|
||||
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<ReturnType<typeof db.query.apps.findMany>>,
|
||||
existingIntegrations: Awaited<ReturnType<typeof db.query.integrations.findMany>>,
|
||||
integrationIdsWithAccess: string[],
|
||||
hasAccessForAll: boolean,
|
||||
): Promise<BundleImportReport> => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { Database, InferSelectModel } from "@homarr/db";
|
||||
import type { groupMembers, groups, searchEngines, serverSettings, users } from "@homarr/db/schema";
|
||||
import { groupMembers as groupMembersTable, groups as groupsTable, searchEngines as searchEnginesTable, serverSettings as serverSettingsTable, users as usersTable } from "@homarr/db/schema";
|
||||
|
||||
export type ExistingEntities = {
|
||||
apps: Awaited<ReturnType<Database["query"]["apps"]["findMany"]>>;
|
||||
integrations: Awaited<ReturnType<Database["query"]["integrations"]["findMany"]>>;
|
||||
boards: Awaited<ReturnType<Database["query"]["boards"]["findMany"]>>;
|
||||
groups: InferSelectModel<typeof groups>[];
|
||||
searchEngines: InferSelectModel<typeof searchEngines>[];
|
||||
users: InferSelectModel<typeof users>[];
|
||||
groupMembers: InferSelectModel<typeof groupMembers>[];
|
||||
serverSettings: InferSelectModel<typeof serverSettings>[];
|
||||
};
|
||||
|
||||
export const loadExistingEntitiesAsync = async (db: Database): Promise<ExistingEntities> => {
|
||||
const [appsResult, integrationsResult, boardsResult, groupsResult, searchEnginesResult, usersResult, groupMembersResult, serverSettingsResult] =
|
||||
await Promise.all([
|
||||
db.query.apps.findMany(),
|
||||
db.query.integrations.findMany(),
|
||||
db.query.boards.findMany(),
|
||||
db.select().from(groupsTable),
|
||||
db.select().from(searchEnginesTable),
|
||||
db.select().from(usersTable),
|
||||
db.select().from(groupMembersTable),
|
||||
db.select().from(serverSettingsTable),
|
||||
]);
|
||||
|
||||
return {
|
||||
apps: appsResult,
|
||||
integrations: integrationsResult,
|
||||
boards: boardsResult,
|
||||
groups: groupsResult,
|
||||
searchEngines: searchEnginesResult,
|
||||
users: usersResult,
|
||||
groupMembers: groupMembersResult,
|
||||
serverSettings: serverSettingsResult,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { InferSelectModel } from "@homarr/db";
|
||||
import type { apps } from "@homarr/db/schema";
|
||||
|
||||
export const doAppsMatch = (
|
||||
app1: Omit<InferSelectModel<typeof apps>, "id">,
|
||||
app2: Omit<InferSelectModel<typeof apps>, "id">,
|
||||
) => {
|
||||
return (
|
||||
app1.name === app2.name &&
|
||||
app1.iconUrl === app2.iconUrl &&
|
||||
app1.description === app2.description &&
|
||||
app1.href === app2.href
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { assessBundleCompatibility } from "../config-bundle-compat";
|
||||
import type { HomarrConfigBundle } from "../schema";
|
||||
import type { ConfigEntityCounts, ConfigImportPreview } from "../types";
|
||||
import { emptyCounts } from "../types";
|
||||
import { loadExistingEntitiesAsync } from "./load-existing-entities";
|
||||
import { doAppsMatch } from "./match-apps";
|
||||
|
||||
const emptyPreview = (
|
||||
compatibility: ConfigImportPreview["compatibility"],
|
||||
meta: ConfigImportPreview["meta"],
|
||||
warnings: string[] = [],
|
||||
): ConfigImportPreview => ({
|
||||
compatibility,
|
||||
meta,
|
||||
toCreate: emptyCounts(),
|
||||
toReuse: { apps: 0, integrations: 0, users: 0 },
|
||||
toSkip: { boards: 0, groups: 0, searchEngines: 0 },
|
||||
toUpdate: { serverSettings: 0 },
|
||||
warnings,
|
||||
});
|
||||
|
||||
const extractMetaFromUnknown = (parsed: unknown): ConfigImportPreview["meta"] => {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
return {
|
||||
exportedAt: typeof obj.exportedAt === "string" ? obj.exportedAt : "",
|
||||
homarrVersion: typeof obj.homarrVersion === "string" ? obj.homarrVersion : "",
|
||||
bundleVersion: typeof obj.version === "string" ? obj.version : "",
|
||||
};
|
||||
};
|
||||
|
||||
const integrationMatches = (
|
||||
bundleIntegration: HomarrConfigBundle["integrations"][number],
|
||||
existing: { kind: IntegrationKind; name: string; url: string },
|
||||
) =>
|
||||
bundleIntegration.kind === existing.kind &&
|
||||
bundleIntegration.name === existing.name &&
|
||||
bundleIntegration.url === existing.url;
|
||||
|
||||
export const previewImportFullConfigAsync = async (
|
||||
db: Database,
|
||||
content: string,
|
||||
currentHomarrVersion: string,
|
||||
): Promise<ConfigImportPreview> => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return emptyPreview(
|
||||
{ status: "invalidStructure", bundleVersion: null, bundleHomarrVersion: null, currentHomarrVersion, issues: ["File is not valid JSON"] },
|
||||
{ exportedAt: "", homarrVersion: "", bundleVersion: "" },
|
||||
);
|
||||
}
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
|
||||
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
return emptyPreview(compatibility, extractMetaFromUnknown(parsed), compatibility.issues);
|
||||
}
|
||||
|
||||
const existing = await loadExistingEntitiesAsync(db);
|
||||
const existingSettingsByKey = new Map(
|
||||
existing.serverSettings.map((setting) => [setting.settingKey, setting.value] as const),
|
||||
);
|
||||
|
||||
const existingBoardNames = new Set(existing.boards.map((b) => b.name));
|
||||
const existingGroupNames = new Set(existing.groups.map((g) => g.name));
|
||||
const existingSearchEngineShorts = new Set(existing.searchEngines.map((se) => se.short));
|
||||
const existingUserEmails = new Set(existing.users.filter((u) => u.email).map((u) => u.email!));
|
||||
|
||||
const toCreate = emptyCounts();
|
||||
const warnings: string[] = [];
|
||||
let reuseApps = 0;
|
||||
let reuseIntegrations = 0;
|
||||
let reuseUsers = 0;
|
||||
let skipBoards = 0;
|
||||
let skipGroups = 0;
|
||||
let skipSearchEngines = 0;
|
||||
let updateServerSettings = 0;
|
||||
|
||||
for (const bundleApp of bundle.apps) {
|
||||
const matched = existing.apps.find((app) =>
|
||||
doAppsMatch(app, {
|
||||
name: bundleApp.name,
|
||||
iconUrl: bundleApp.iconUrl,
|
||||
description: bundleApp.description ?? null,
|
||||
href: bundleApp.href,
|
||||
pingUrl: bundleApp.pingUrl ?? null,
|
||||
}),
|
||||
);
|
||||
if (matched) {
|
||||
reuseApps += 1;
|
||||
continue;
|
||||
}
|
||||
toCreate.apps += 1;
|
||||
}
|
||||
|
||||
for (const bundleIntegration of bundle.integrations) {
|
||||
const matched = existing.integrations.find((i) => integrationMatches(bundleIntegration, i));
|
||||
if (matched) {
|
||||
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;
|
||||
toCreate.widgets += bundleBoard.items.length;
|
||||
toCreate.sections += bundleBoard.sections.length;
|
||||
toCreate.layouts += bundleBoard.layouts.length;
|
||||
}
|
||||
|
||||
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 bundleUser of bundle.users ?? []) {
|
||||
if (bundleUser.email && existingUserEmails.has(bundleUser.email)) {
|
||||
reuseUsers += 1;
|
||||
continue;
|
||||
}
|
||||
toCreate.users += 1;
|
||||
}
|
||||
|
||||
for (const [settingKey, value] of Object.entries(bundle.serverSettings)) {
|
||||
const serialized = SuperJSON.stringify(value);
|
||||
const existingValue = existingSettingsByKey.get(settingKey);
|
||||
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, users: reuseUsers },
|
||||
toSkip: { boards: skipBoards, groups: skipGroups, searchEngines: skipSearchEngines },
|
||||
toUpdate: { serverSettings: updateServerSettings },
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
@@ -1,203 +0,0 @@
|
||||
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 type { HomarrBundle, HomarrBundleBoard } from "../schema";
|
||||
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
|
||||
import { doAppsMatch } from "./match-apps";
|
||||
|
||||
export type PreparedBundleImport = {
|
||||
apps: InferInsertModel<typeof apps>[];
|
||||
board: InferInsertModel<typeof boards>;
|
||||
layouts: InferInsertModel<typeof layouts>[];
|
||||
sections: InferInsertModel<typeof sections>[];
|
||||
sectionLayouts: InferInsertModel<typeof sectionLayouts>[];
|
||||
items: InferInsertModel<typeof items>[];
|
||||
itemLayouts: InferInsertModel<typeof itemLayouts>[];
|
||||
integrationItems: InferInsertModel<typeof integrationItems>[];
|
||||
warnings: string[];
|
||||
createdAppsCount: number;
|
||||
createdItemsCount: number;
|
||||
};
|
||||
|
||||
const integrationMatches = (
|
||||
bundleIntegration: HomarrBundle["integrations"][number],
|
||||
existing: InferSelectModel<typeof integrations>,
|
||||
) => {
|
||||
return (
|
||||
bundleIntegration.kind === existing.kind &&
|
||||
bundleIntegration.name === existing.name &&
|
||||
bundleIntegration.url === existing.url
|
||||
);
|
||||
};
|
||||
|
||||
export const prepareBoardImport = (
|
||||
bundle: HomarrBundle,
|
||||
boardName: string,
|
||||
creatorId: string,
|
||||
existingApps: InferSelectModel<typeof apps>[],
|
||||
existingIntegrations: InferSelectModel<typeof integrations>[],
|
||||
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<string, string>();
|
||||
const appsToInsert: InferInsertModel<typeof apps>[] = [];
|
||||
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<string, string>();
|
||||
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"] = buildBoardInsertRow(boardId, boardName, creatorId, sourceBoard.settings);
|
||||
|
||||
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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
export type EntityResolverDef<TBundle, TExisting extends { id: string }, TInsert> = {
|
||||
getRef: (bundle: TBundle) => string;
|
||||
match: (bundle: TBundle, existing: TExisting) => boolean;
|
||||
toInsertRow: (bundle: TBundle, newId: string) => TInsert;
|
||||
skipMessage?: (bundle: TBundle) => string;
|
||||
};
|
||||
|
||||
export type ResolveResult<TInsert> = {
|
||||
refToId: Map<string, string>;
|
||||
rows: TInsert[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export const resolveEntities = <TBundle, TExisting extends { id: string }, TInsert>(
|
||||
bundleEntities: TBundle[],
|
||||
existingEntities: TExisting[],
|
||||
def: EntityResolverDef<TBundle, TExisting, TInsert>,
|
||||
): ResolveResult<TInsert> => {
|
||||
const refToId = new Map<string, string>();
|
||||
const rows: TInsert[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const bundleEntity of bundleEntities) {
|
||||
const existing = existingEntities.find((e) => def.match(bundleEntity, e));
|
||||
|
||||
if (existing) {
|
||||
refToId.set(def.getRef(bundleEntity), existing.id);
|
||||
if (def.skipMessage) warnings.push(def.skipMessage(bundleEntity));
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = createId();
|
||||
refToId.set(def.getRef(bundleEntity), newId);
|
||||
rows.push(def.toInsertRow(bundleEntity, newId));
|
||||
}
|
||||
|
||||
return { refToId, rows, warnings };
|
||||
};
|
||||
|
||||
export const groupByKey = <T, K extends string | number>(
|
||||
items: T[],
|
||||
getKey: (item: T) => K,
|
||||
): Map<K, T[]> => {
|
||||
const map = new Map<K, T[]>();
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(item);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
@@ -1,199 +0,0 @@
|
||||
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({
|
||||
ref: z.string().optional(),
|
||||
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<typeof homarrBundleSchema>;
|
||||
export type HomarrBundleBoard = z.infer<typeof bundleBoardSchema>;
|
||||
export type HomarrBundleApp = z.infer<typeof bundleAppSchema>;
|
||||
export type HomarrBundleIntegration = z.infer<typeof bundleIntegrationSchema>;
|
||||
|
||||
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 configBundleUserSchema = z
|
||||
.object({
|
||||
ref: z.string(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
password: z.string().nullable(),
|
||||
provider: z.string(),
|
||||
homeBoardRef: z.string().nullable(),
|
||||
mobileHomeBoardRef: z.string().nullable(),
|
||||
defaultSearchEngineRef: z.string().nullable(),
|
||||
colorScheme: z.string(),
|
||||
firstDayOfWeek: z.number(),
|
||||
openSearchInNewTab: z.boolean(),
|
||||
ddgBangs: z.boolean(),
|
||||
pingIconsEnabled: z.boolean(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
const configBundleGroupSchema = z.object({
|
||||
ref: z.string(),
|
||||
name: z.string(),
|
||||
position: z.number(),
|
||||
ownerRef: z.string().nullable().optional(),
|
||||
homeBoardRef: z.string().nullable().optional(),
|
||||
mobileHomeBoardRef: z.string().nullable().optional(),
|
||||
permissions: z.array(z.string()),
|
||||
boardPermissions: z.array(
|
||||
z.object({ boardRef: z.string(), permission: z.string() }),
|
||||
),
|
||||
integrationPermissions: z.array(
|
||||
z.object({ integrationRef: z.string(), permission: z.string() }),
|
||||
),
|
||||
memberRefs: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
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),
|
||||
users: z.array(configBundleUserSchema).optional(),
|
||||
});
|
||||
|
||||
export type HomarrConfigBundle = z.infer<typeof homarrConfigBundleSchema>;
|
||||
export type ConfigBundleIntegration = z.infer<typeof configBundleIntegrationSchema>;
|
||||
export type ConfigBundleGroup = z.infer<typeof configBundleGroupSchema>;
|
||||
export type ConfigBundleSearchEngine = z.infer<typeof configBundleSearchEngineSchema>;
|
||||
export type ConfigBundleUser = z.infer<typeof configBundleUserSchema>;
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { BundleCompatibility } from "./config-bundle-compat";
|
||||
|
||||
export const CONFIG_ENTITY_KEYS = [
|
||||
"boards",
|
||||
"apps",
|
||||
"integrations",
|
||||
"secrets",
|
||||
"widgets",
|
||||
"sections",
|
||||
"layouts",
|
||||
"searchEngines",
|
||||
"groups",
|
||||
"users",
|
||||
"serverSettings",
|
||||
] as const;
|
||||
|
||||
export type ConfigEntityKey = (typeof CONFIG_ENTITY_KEYS)[number];
|
||||
|
||||
export type ConfigEntityCounts = Record<ConfigEntityKey, number>;
|
||||
|
||||
export const emptyCounts = (): ConfigEntityCounts =>
|
||||
Object.fromEntries(CONFIG_ENTITY_KEYS.map((key) => [key, 0])) as ConfigEntityCounts;
|
||||
|
||||
export type ConfigExportPreview = ConfigEntityCounts;
|
||||
|
||||
export type ConfigImportPreview = {
|
||||
compatibility: BundleCompatibility;
|
||||
meta: {
|
||||
exportedAt: string;
|
||||
homarrVersion: string;
|
||||
bundleVersion: string;
|
||||
};
|
||||
toCreate: ConfigEntityCounts;
|
||||
toReuse: {
|
||||
apps: number;
|
||||
integrations: number;
|
||||
users: number;
|
||||
};
|
||||
toSkip: {
|
||||
boards: number;
|
||||
groups: number;
|
||||
searchEngines: number;
|
||||
};
|
||||
toUpdate: {
|
||||
serverSettings: number;
|
||||
};
|
||||
warnings: string[];
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type { boards } from "@homarr/db/schema";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes, emptySuperJSON } from "@homarr/definitions";
|
||||
|
||||
import type { HomarrBundleBoard } from "./schema";
|
||||
|
||||
export const parseStoredValue = <T>(value: string | null | undefined, fallback: T): T => {
|
||||
if (!value || value === emptySuperJSON) {
|
||||
return fallback;
|
||||
}
|
||||
return SuperJSON.parse(value) as T;
|
||||
};
|
||||
|
||||
export const stringifyForDb = (value: unknown): string => {
|
||||
return SuperJSON.stringify(value);
|
||||
};
|
||||
|
||||
export const mustGet = <T>(map: Map<string, T>, key: string): T => {
|
||||
const value = map.get(key);
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing map entry for key "${key}"`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const replaceMapValues = (value: unknown, map: Map<string, string>): unknown => {
|
||||
if (typeof value === "string") {
|
||||
return map.get(value) ?? value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => replaceMapValues(entry, map));
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, replaceMapValues(entry, map)]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const replaceAppIdsInValue = replaceMapValues;
|
||||
export const replaceAppRefsInValue = replaceMapValues;
|
||||
|
||||
export const buildBoardInsertRow = (
|
||||
boardId: string,
|
||||
name: string,
|
||||
creatorId: string | null,
|
||||
settings: HomarrBundleBoard["settings"],
|
||||
): InferInsertModel<typeof boards> => ({
|
||||
id: boardId,
|
||||
name,
|
||||
creatorId,
|
||||
isPublic: settings.isPublic ?? false,
|
||||
pageTitle: settings.pageTitle ?? null,
|
||||
metaTitle: settings.metaTitle ?? null,
|
||||
logoImageUrl: settings.logoImageUrl ?? null,
|
||||
faviconImageUrl: settings.faviconImageUrl ?? null,
|
||||
backgroundImageUrl: settings.backgroundImageUrl ?? null,
|
||||
backgroundImageAttachment: settings.backgroundImageAttachment ?? backgroundImageAttachments.defaultValue,
|
||||
backgroundImageRepeat: settings.backgroundImageRepeat ?? backgroundImageRepeats.defaultValue,
|
||||
backgroundImageSize: settings.backgroundImageSize ?? backgroundImageSizes.defaultValue,
|
||||
primaryColor: settings.primaryColor,
|
||||
secondaryColor: settings.secondaryColor,
|
||||
opacity: settings.opacity,
|
||||
customCss: settings.customCss ?? null,
|
||||
iconColor: settings.iconColor ?? null,
|
||||
itemRadius: settings.itemRadius,
|
||||
disableStatus: settings.disableStatus ?? false,
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -26,7 +26,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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-<name>.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}`);
|
||||
},
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
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"}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
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],
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
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, boardsRoot, resetPassword, fixUsernames, recreateAdmin];
|
||||
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
|
||||
|
||||
void run(commands, {
|
||||
name: "homarr-cli",
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE `docker_app_source` (
|
||||
`host` varchar(512) NOT NULL,
|
||||
`container_id` varchar(128) NOT NULL,
|
||||
`external_id` varchar(512) NOT NULL,
|
||||
`app_id` varchar(64) NOT NULL,
|
||||
`board_id` varchar(64) NOT NULL,
|
||||
`item_id` varchar(64),
|
||||
`integration_id` varchar(64),
|
||||
`updated_at` timestamp NOT NULL,
|
||||
CONSTRAINT `docker_app_source_host_container_id_pk` PRIMARY KEY(`host`,`container_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -281,13 +281,6 @@
|
||||
"when": 1779199104477,
|
||||
"tag": "0039_add_user_onboarding_tour_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "5",
|
||||
"when": 1780490691902,
|
||||
"tag": "0040_add_docker_app_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE "docker_app_source" (
|
||||
"host" varchar(512) NOT NULL,
|
||||
"container_id" varchar(128) NOT NULL,
|
||||
"external_id" varchar(512) NOT NULL,
|
||||
"app_id" varchar(64) NOT NULL,
|
||||
"board_id" varchar(64) NOT NULL,
|
||||
"item_id" varchar(64),
|
||||
"integration_id" varchar(64),
|
||||
"updated_at" timestamp NOT NULL,
|
||||
CONSTRAINT "docker_app_source_host_container_id_pk" PRIMARY KEY("host","container_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "docker_app_source__external_id_idx" ON "docker_app_source" USING btree ("host","external_id");
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user