Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6a598b774 | ||
|
|
1f7b7f0516 | ||
|
|
fb8721bd4e |
No files matched your search
@@ -1,4 +1,5 @@
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
.dockerignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
@@ -14,3 +15,10 @@ apps/nextjs/.next
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.docker-data
|
||||
.docker-cache
|
||||
coverage
|
||||
.cursor
|
||||
agent-transcripts
|
||||
**/*.spec.ts
|
||||
**/*.test.ts
|
||||
@@ -72,3 +72,7 @@ en.d.json.ts
|
||||
# personal agent config
|
||||
AGENTS.md
|
||||
.opencode
|
||||
|
||||
# local docker dev
|
||||
.docker-data/
|
||||
.docker-cache/
|
||||
@@ -22,6 +22,7 @@
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/board-portability": "workspace:^0.1.0",
|
||||
"@homarr/boards": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
@@ -64,7 +65,6 @@
|
||||
"@trpc/next": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"@xterm/addon-canvas": "catalog:",
|
||||
"@xterm/addon-fit": "catalog:",
|
||||
"@xterm/xterm": "catalog:",
|
||||
"babel-plugin-react-compiler": "catalog:",
|
||||
|
||||
@@ -6,11 +6,14 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { clearInitImportMode } from "./import/use-init-import-mode";
|
||||
|
||||
export const BackToStart = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
|
||||
|
||||
const handleBackToStartAsync = async () => {
|
||||
clearInitImportMode();
|
||||
await mutateAsync();
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"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,8 +11,20 @@ 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>(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type InitImportMode = "legacy" | "config";
|
||||
|
||||
const storageKey = "homarr-init-import-mode";
|
||||
|
||||
const readMode = (): InitImportMode => {
|
||||
const stored = sessionStorage.getItem(storageKey);
|
||||
return stored === "config" ? "config" : "legacy";
|
||||
};
|
||||
|
||||
const subscribe = (onStoreChange: () => void) => {
|
||||
window.addEventListener("storage", onStoreChange);
|
||||
return () => window.removeEventListener("storage", onStoreChange);
|
||||
};
|
||||
|
||||
export const setInitImportMode = (mode: InitImportMode) => {
|
||||
sessionStorage.setItem(storageKey, mode);
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
};
|
||||
|
||||
export const clearInitImportMode = () => {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
};
|
||||
|
||||
export const useInitImportMode = () =>
|
||||
useSyncExternalStore(subscribe, readMode, () => "legacy" as InitImportMode);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { IconFileImport, IconJson, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
@@ -23,10 +23,18 @@ 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>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
|
||||
@@ -7,15 +7,28 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { clearInitImportMode, setInitImportMode, type InitImportMode } from "../import/use-init-import-mode";
|
||||
|
||||
interface InitStartButtonProps {
|
||||
icon: ReactNode;
|
||||
preferredStep: OnboardingStep | undefined;
|
||||
importMode?: InitImportMode;
|
||||
}
|
||||
|
||||
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
|
||||
export const InitStartButton = ({
|
||||
preferredStep,
|
||||
icon,
|
||||
children,
|
||||
importMode,
|
||||
}: PropsWithChildren<InitStartButtonProps>) => {
|
||||
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
if (importMode) {
|
||||
setInitImportMode(importMode);
|
||||
} else {
|
||||
clearInitImportMode();
|
||||
}
|
||||
await mutateAsync({ preferredStep });
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
+19
-3
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDeviceMobile, IconDownload, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { DuplicateBoardModal, ExportBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
@@ -28,7 +28,10 @@ interface BoardCardMenuDropdownProps {
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
const t = useScopedI18n("management.page.board.action");
|
||||
const t = useScopedI18n("management.page.board.action" as never) as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, unknown>,
|
||||
) => string;
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
@@ -36,6 +39,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
||||
const { openModal: openExportModal } = useModalAction(ExportBoardModal);
|
||||
|
||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
@@ -87,6 +91,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
});
|
||||
}, [board.id, board.name, openDuplicateModal]);
|
||||
|
||||
const handleExportBoard = useCallback(() => {
|
||||
openExportModal({
|
||||
board: {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, openExportModal]);
|
||||
|
||||
return (
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||
@@ -95,6 +108,9 @@ 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();
|
||||
const t = useI18n() as unknown as (key: string) => string;
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconBrandTablerFilled,
|
||||
IconCertificate,
|
||||
IconClipboardListFilled,
|
||||
IconDatabaseExport,
|
||||
IconDirectionsFilled,
|
||||
IconGitFork,
|
||||
IconHelpSquareRoundedFilled,
|
||||
@@ -154,6 +155,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/tools/tasks",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
|
||||
icon: IconDatabaseExport,
|
||||
href: "/manage/tools/import-export",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"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,12 +5,15 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||
import { DockerSettingsForm } from "./_components/docker-settings-form";
|
||||
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||
import { UserSettingsForm } from "./_components/user-settings-form";
|
||||
|
||||
@@ -59,6 +62,12 @@ export default async function SettingsPage() {
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
@@ -45,34 +44,34 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const canvasAddon = new CanvasAddon();
|
||||
|
||||
terminalRef.current = new Terminal({
|
||||
const fitAddon = new FitAddon();
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
});
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
terminal.open(ref.current);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminalRef.current = terminal;
|
||||
|
||||
isTerminalReadyRef.current = true;
|
||||
const terminal = terminalRef.current;
|
||||
pendingLogsRef.current.forEach((data) => terminal.write(data));
|
||||
pendingLogsRef.current = [];
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
|
||||
setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
});
|
||||
const fitTimeout = setTimeout(() => fitAddon.fit());
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
|
||||
resizeObserver.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
clearTimeout(fitTimeout);
|
||||
resizeObserver.disconnect();
|
||||
isTerminalReadyRef.current = false;
|
||||
pendingLogsRef.current = [];
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [writeToTerminal]);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Badge, Button, Card, Group, Stack, Table, Text, Title } from "@mantine/core";
|
||||
import { IconRefresh, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const DockerDiscoveryPanel = () => {
|
||||
const tDiscovery = useScopedI18n("docker.discovery" as never) as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Card, Group, List, Loader, Popover, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconDatabaseExport } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ConfigSummaryList } from "~/components/config-import/config-summary-list";
|
||||
|
||||
const exportWarningPointKeys = [
|
||||
"management.page.importExport.export.warning.message",
|
||||
"management.page.importExport.export.warning.storage",
|
||||
"management.page.importExport.export.warning.sharing",
|
||||
] as const;
|
||||
|
||||
export const ExportConfigCard = () => {
|
||||
const t = useI18n() as unknown as (key: string, values?: Record<string, 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Card, FileInput, Group, List, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconCheck, IconFileUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
|
||||
|
||||
export const ImportConfigCard = () => {
|
||||
const t = useI18n() as unknown as (key: string, values?: Record<string, 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Card, Code, FileInput, Group, Stack, Switch, Text, TextInput, Title } from "@mantine/core";
|
||||
import { IconFileUpload } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
extractHomepageEnvVariables,
|
||||
parseServicesYaml,
|
||||
replaceHomepageEnvVariables,
|
||||
} from "@homarr/board-portability/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardNameSchema } from "@homarr/validation/board";
|
||||
|
||||
export const ImportHomepageCard = () => {
|
||||
const t = useI18n() as unknown as (key: string) => string;
|
||||
const [fileValid, setFileValid] = useState(true);
|
||||
const [rawContent, setRawContent] = useState("");
|
||||
const [envVars, setEnvVars] = useState<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { DockerDiscoveryPanel } from "./docker-discovery-panel";
|
||||
import { ExportConfigCard } from "./export-config-card";
|
||||
import { ImportConfigCard } from "./import-config-card";
|
||||
import { ImportHomepageCard } from "./import-homepage-card";
|
||||
|
||||
export default async function ImportExportPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.importExport" as never) as unknown as (key: string) => string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<ExportConfigCard />
|
||||
<ImportConfigCard />
|
||||
<ImportHomepageCard />
|
||||
{env.ENABLE_DOCKER && <DockerDiscoveryPanel />}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
@@ -15,8 +14,8 @@ export const TerminalComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { activeLevels, fontSize } = useLogContext();
|
||||
|
||||
const terminalRef = useRef<Terminal>(null);
|
||||
const fitAddonRef = useRef<FitAddon>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
|
||||
clientApi.log.subscribe.useSubscription(
|
||||
{
|
||||
@@ -38,28 +37,31 @@ export const TerminalComponent = () => {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const canvasAddon = new CanvasAddon();
|
||||
|
||||
terminalRef.current = new Terminal({
|
||||
const fitAddon = new FitAddon();
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize,
|
||||
});
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
terminal.open(ref.current);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminalRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
});
|
||||
const fitTerminal = () => fitAddon.fit();
|
||||
const fitTimeout = setTimeout(fitTerminal);
|
||||
|
||||
const resizeObserver = new ResizeObserver(fitTerminal);
|
||||
resizeObserver.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
clearTimeout(fitTimeout);
|
||||
resizeObserver.disconnect();
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
# 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
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/board-portability": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
|
||||
config: lazy(() => import("./router/config").then((mod) => mod.configRouter)),
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
getPermissionsWithParents,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import {
|
||||
exportBoardBundleAsync,
|
||||
importBoardBundleAsync,
|
||||
importHomepageServicesAsync,
|
||||
parseBundleJson,
|
||||
} from "@homarr/board-portability";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
@@ -43,6 +49,9 @@ import {
|
||||
boardChangeVisibilitySchema,
|
||||
boardCreateSchema,
|
||||
boardDuplicateSchema,
|
||||
boardExportBundleSchema,
|
||||
boardImportBundleSchema,
|
||||
boardNameSchema,
|
||||
boardRenameSchema,
|
||||
boardSaveLayoutsSchema,
|
||||
boardSavePartialSettingsSchema,
|
||||
@@ -54,6 +63,7 @@ import { zodUnionFromArray } from "@homarr/validation/enums";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
||||
@@ -1327,6 +1337,113 @@ export const boardRouter = createTRPCRouter({
|
||||
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
||||
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
|
||||
}),
|
||||
exportBundle: protectedProcedure.input(boardExportBundleSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
|
||||
const result = await exportBoardBundleAsync(ctx.db, input.id, packageJson.version);
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
filename: result.filename,
|
||||
content: result.content,
|
||||
};
|
||||
}),
|
||||
importBundle: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(boardImportBundleSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name);
|
||||
|
||||
const bundle = parseBundleJson(input.content);
|
||||
|
||||
const existingApps = await ctx.db.query.apps.findMany();
|
||||
const existingIntegrations = await ctx.db.query.integrations.findMany();
|
||||
|
||||
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||
const integrationIdsWithAccess = hasAccessForAll
|
||||
? []
|
||||
: await ctx.db
|
||||
.selectDistinct({
|
||||
id: integrationGroupPermissions.integrationId,
|
||||
})
|
||||
.from(integrationGroupPermissions)
|
||||
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
|
||||
.where(eq(groupMembers.userId, ctx.session.user.id))
|
||||
.union(
|
||||
ctx.db
|
||||
.selectDistinct({ id: integrationUserPermissions.integrationId })
|
||||
.from(integrationUserPermissions)
|
||||
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
|
||||
)
|
||||
.then((result) => result.map((row) => row.id));
|
||||
|
||||
const report = await importBoardBundleAsync(
|
||||
ctx.db,
|
||||
bundle,
|
||||
input.name,
|
||||
ctx.session.user.id,
|
||||
existingApps,
|
||||
existingIntegrations,
|
||||
integrationIdsWithAccess,
|
||||
hasAccessForAll,
|
||||
);
|
||||
|
||||
return {
|
||||
boardId: report.boardId,
|
||||
created: report.created,
|
||||
warnings: report.warnings,
|
||||
};
|
||||
}),
|
||||
importHomepageServices: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
boardName: boardNameSchema,
|
||||
createIntegrations: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.boardName);
|
||||
|
||||
try {
|
||||
const report = await importHomepageServicesAsync(
|
||||
ctx.db,
|
||||
input.content,
|
||||
input.boardName,
|
||||
ctx.session.user.id,
|
||||
input.createIntegrations,
|
||||
);
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
columns: {
|
||||
homeBoardId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.homeBoardId) {
|
||||
await ctx.db.update(users).set({ homeBoardId: report.boardId }).where(eq(users.id, ctx.session.user.id));
|
||||
}
|
||||
|
||||
return {
|
||||
boardId: report.boardId,
|
||||
created: report.created,
|
||||
warnings: report.warnings,
|
||||
unmappedWidgetTypes: report.unmappedWidgetTypes,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error instanceof Error ? error.message : "Could not import Homepage services",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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,7 +3,8 @@ import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
dockerContainersRequestHandler,
|
||||
getContainerLogsAsync,
|
||||
@@ -108,6 +109,32 @@ export const dockerRouter = createTRPCRouter({
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
getDiscoveredPreview: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => {
|
||||
const serverSettings = await getServerSettingsAsync(ctx.db);
|
||||
return await listDiscoveredContainersAsync({
|
||||
readHomepageLabels: serverSettings.docker.readHomepageLabels,
|
||||
});
|
||||
}),
|
||||
syncDiscovery: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.mutation(async ({ ctx }) => {
|
||||
const serverSettings = await getServerSettingsAsync(ctx.db);
|
||||
const discovered = await listDiscoveredContainersAsync({
|
||||
readHomepageLabels: serverSettings.docker.readHomepageLabels,
|
||||
});
|
||||
|
||||
return await syncDiscoveredServicesAsync(discovered, {
|
||||
targetBoardName: serverSettings.docker.targetBoardName,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
defaultItemWidth: serverSettings.docker.defaultItemWidth,
|
||||
defaultItemHeight: serverSettings.docker.defaultItemHeight,
|
||||
});
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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,
|
||||
@@ -7,6 +10,7 @@ import {
|
||||
importInitialOldmarrInputSchema,
|
||||
} from "@homarr/old-import/import";
|
||||
|
||||
import packageJson from "../../../../../package.json";
|
||||
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
|
||||
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
|
||||
|
||||
@@ -19,12 +23,7 @@ export const importRouter = createTRPCRouter({
|
||||
}),
|
||||
validateToken: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
checksum: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ checksum: z.string(), token: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
ensureValidTokenOrThrow(input.checksum, input.token);
|
||||
@@ -40,4 +39,28 @@ 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,6 +50,19 @@ 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[]) =>
|
||||
({
|
||||
@@ -73,6 +86,8 @@ 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,23 +52,45 @@ export const userRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.email, input.email),
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
|
||||
const userId = existingUser ? existingUser.id : await createUserAsync(ctx.db, input);
|
||||
|
||||
const existingGroup = await ctx.db.query.groups.findFirst({
|
||||
where: eq(groups.name, credentialsAdminGroup),
|
||||
});
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
|
||||
let groupId: string;
|
||||
if (existingGroup) {
|
||||
groupId = existingGroup.id;
|
||||
} else {
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
}
|
||||
|
||||
const existingMembership = await ctx.db.query.groupMembers.findFirst({
|
||||
where: and(eq(groupMembers.groupId, groupId), eq(groupMembers.userId, userId)),
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
register: publicProcedure
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
homarrBundleSchema,
|
||||
homarrConfigBundleSchema,
|
||||
type HomarrBundle,
|
||||
type HomarrConfigBundle,
|
||||
} from "./src/schema";
|
||||
export { parseServicesYaml } from "./src/homepage/parse-services-yaml";
|
||||
export { extractHomepageEnvVariables, replaceHomepageEnvVariables } from "./src/homepage/env-variables";
|
||||
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./src/homepage/types";
|
||||
@@ -0,0 +1,25 @@
|
||||
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";
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@homarr/board-portability",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./client": "./client.ts",
|
||||
"./homepage": "./src/homepage/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"superjson": "catalog:",
|
||||
"yaml": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { HomarrBundle } from "../schema";
|
||||
import { homarrBundleSchema } from "../schema";
|
||||
import { bundleToJson, bundleFilenameForBoard } from "../export/to-json";
|
||||
import { parseBundleJson } from "../import/parse-bundle";
|
||||
|
||||
const createMinimalBundle = (overrides: Partial<HomarrBundle> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { HomarrConfigBundle } from "../schema";
|
||||
import { homarrConfigBundleSchema } from "../schema";
|
||||
import { assessBundleCompatibility, parseConfigBundleJson } from "../config-bundle-compat";
|
||||
|
||||
const createMinimalConfigBundle = (overrides: Partial<HomarrConfigBundle> = {}): HomarrConfigBundle => ({
|
||||
version: "2.0",
|
||||
type: "full-config",
|
||||
exportedAt: new Date().toISOString(),
|
||||
homarrVersion: "1.5.0",
|
||||
encryptionKey: "a".repeat(64),
|
||||
boards: [
|
||||
{
|
||||
ref: "board-uuid-1",
|
||||
name: "Main Dashboard",
|
||||
settings: {
|
||||
primaryColor: "#FA5252",
|
||||
secondaryColor: "#868E96",
|
||||
opacity: 100,
|
||||
itemRadius: "md",
|
||||
},
|
||||
layouts: [
|
||||
{ ref: "layout-1", name: "Default", columnCount: 10, breakpoint: 0 },
|
||||
],
|
||||
sections: [
|
||||
{ ref: "section-1", kind: "category", name: "Media", yOffset: 0, xOffset: 0 },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
ref: "item-1",
|
||||
kind: "app",
|
||||
options: { appId: "app-ref-1", openInNewTab: true },
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
integrationRefs: ["int-ref-1"],
|
||||
appRef: "app-ref-1",
|
||||
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 0, y: 0, w: 2, h: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
ref: "app-ref-1",
|
||||
name: "Jellyfin",
|
||||
href: "https://jellyfin.local",
|
||||
iconUrl: "https://cdn.example/jellyfin.png",
|
||||
description: "Media server",
|
||||
pingUrl: null,
|
||||
},
|
||||
],
|
||||
integrations: [
|
||||
{
|
||||
ref: "int-ref-1",
|
||||
kind: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
url: "https://jellyfin.local",
|
||||
secrets: [{ kind: "apiKey", value: "encrypted.value" }],
|
||||
},
|
||||
],
|
||||
serverSettings: {
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
docker: { targetBoardName: null, readHomepageLabels: true },
|
||||
},
|
||||
searchEngines: [
|
||||
{
|
||||
ref: "se-1",
|
||||
iconUrl: "https://google.com/favicon.ico",
|
||||
name: "Google",
|
||||
short: "g",
|
||||
description: "Google search",
|
||||
urlTemplate: "https://google.com/search?q={query}",
|
||||
type: "generic",
|
||||
integrationRef: null,
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
ref: "group-1",
|
||||
name: "Admins",
|
||||
position: 0,
|
||||
ownerRef: "user-1",
|
||||
homeBoardRef: null,
|
||||
mobileHomeBoardRef: null,
|
||||
permissions: ["admin"],
|
||||
boardPermissions: [{ boardRef: "board-uuid-1", permission: "board-full-access" }],
|
||||
integrationPermissions: [{ integrationRef: "int-ref-1", permission: "integration-use-credentials" }],
|
||||
memberRefs: ["user-1"],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
ref: "user-1",
|
||||
name: "admin",
|
||||
email: "admin@example.com",
|
||||
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
|
||||
provider: "credentials",
|
||||
homeBoardRef: "board-uuid-1",
|
||||
mobileHomeBoardRef: null,
|
||||
defaultSearchEngineRef: "se-1",
|
||||
colorScheme: "dark",
|
||||
firstDayOfWeek: 1,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
pingIconsEnabled: false,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("v2.0 config bundle schema", () => {
|
||||
test("schema validates a valid config bundle", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("schema rejects v1.0 version for config bundle", () => {
|
||||
const invalid = { ...createMinimalConfigBundle(), version: "1.0" };
|
||||
const result = homarrConfigBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("schema rejects missing type field", () => {
|
||||
const { type: _type, ...invalid } = createMinimalConfigBundle();
|
||||
const result = homarrConfigBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("schema accepts bundle with no boards", () => {
|
||||
const bundle = createMinimalConfigBundle({ boards: [] });
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("schema accepts board with optional ref field", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
delete (bundle.boards[0] as Record<string, unknown>).ref;
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2.0 config bundle round-trip", () => {
|
||||
test("serialized config bundle parses back correctly", () => {
|
||||
const original = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(original);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
|
||||
expect(parsed.version).toBe("2.0");
|
||||
expect(parsed.type).toBe("full-config");
|
||||
expect(parsed.homarrVersion).toBe("1.5.0");
|
||||
expect(parsed.boards).toHaveLength(1);
|
||||
expect(parsed.boards[0]?.ref).toBe("board-uuid-1");
|
||||
expect(parsed.boards[0]?.name).toBe("Main Dashboard");
|
||||
expect(parsed.apps).toHaveLength(1);
|
||||
expect(parsed.integrations).toHaveLength(1);
|
||||
expect(parsed.integrations[0]?.secrets).toHaveLength(1);
|
||||
expect(parsed.searchEngines).toHaveLength(1);
|
||||
expect(parsed.groups).toHaveLength(1);
|
||||
expect(parsed.groups[0]?.boardPermissions).toHaveLength(1);
|
||||
expect(parsed.groups[0]?.boardPermissions[0]?.boardRef).toBe("board-uuid-1");
|
||||
});
|
||||
|
||||
test("preserves encryption key", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.encryptionKey).toBe("a".repeat(64));
|
||||
});
|
||||
|
||||
test("preserves server settings", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.serverSettings).toEqual({
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
docker: { targetBoardName: null, readHomepageLabels: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves users and group memberships", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
|
||||
expect(parsed.users).toHaveLength(1);
|
||||
expect(parsed.users![0]?.email).toBe("admin@example.com");
|
||||
expect(parsed.users![0]?.provider).toBe("credentials");
|
||||
expect(parsed.users![0]?.homeBoardRef).toBe("board-uuid-1");
|
||||
expect(parsed.groups[0]?.memberRefs).toEqual(["user-1"]);
|
||||
expect(parsed.groups[0]?.ownerRef).toBe("user-1");
|
||||
});
|
||||
|
||||
test("accepts bundle without users (backward compat)", () => {
|
||||
const { users: _users, ...bundleWithoutUsers } = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundleWithoutUsers);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.users).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assessBundleCompatibility", () => {
|
||||
test("marks valid bundle as compatible", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("compatible");
|
||||
expect(result.bundle).not.toBeNull();
|
||||
expect(result.compatibility.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("marks compatible even with version mismatch (different Homarr version)", () => {
|
||||
const bundle = createMinimalConfigBundle({ homarrVersion: "1.4.0" });
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("compatible");
|
||||
expect(result.compatibility.issues).toHaveLength(1);
|
||||
expect(result.compatibility.issues[0]).toContain("1.4.0");
|
||||
});
|
||||
|
||||
test("rejects unsupported format version", () => {
|
||||
const bundle = { ...createMinimalConfigBundle(), version: "3.0" };
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("unsupportedVersion");
|
||||
expect(result.bundle).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects non-object input", () => {
|
||||
const result = assessBundleCompatibility(null, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("invalidStructure");
|
||||
expect(result.bundle).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects malformed structure", () => {
|
||||
const result = assessBundleCompatibility({ version: "2.0", type: "full-config" }, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("invalidStructure");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Declarative field definitions for entities in the config bundle.
|
||||
*
|
||||
* To add a new user preference:
|
||||
* 1. Add the column to the DB schema (migration)
|
||||
* 2. Add the field name to USER_DIRECT_FIELDS below
|
||||
* That's it — export, import, and schema all derive from this.
|
||||
*
|
||||
* To add a new FK reference on users:
|
||||
* 1. Add the column to the DB schema (migration)
|
||||
* 2. Add an entry to USER_REF_FIELDS below (bundleKey → dbColumn → refMap name)
|
||||
*/
|
||||
|
||||
/**
|
||||
* User fields copied directly between bundle and DB (no transformation).
|
||||
* Adding a new preference here automatically includes it in export AND import.
|
||||
*/
|
||||
export const USER_DIRECT_FIELDS = [
|
||||
"name",
|
||||
"email",
|
||||
"password",
|
||||
"provider",
|
||||
"colorScheme",
|
||||
"firstDayOfWeek",
|
||||
"openSearchInNewTab",
|
||||
"ddgBangs",
|
||||
"pingIconsEnabled",
|
||||
] as const;
|
||||
|
||||
export type UserDirectField = (typeof USER_DIRECT_FIELDS)[number];
|
||||
|
||||
/**
|
||||
* User FK reference fields: bundle uses `xxxRef`, DB uses `xxxId`.
|
||||
* Key = bundle field name (with "Ref" suffix)
|
||||
* Value = which ref map to use during import (matches the entity group name)
|
||||
*/
|
||||
export const USER_REF_FIELDS = {
|
||||
homeBoardRef: "boards",
|
||||
mobileHomeBoardRef: "boards",
|
||||
defaultSearchEngineRef: "searchEngines",
|
||||
} as const;
|
||||
|
||||
export type UserRefField = keyof typeof USER_REF_FIELDS;
|
||||
|
||||
/**
|
||||
* Derives the DB column name from a bundle ref field name.
|
||||
* "homeBoardRef" → "homeBoardId"
|
||||
*/
|
||||
export const refFieldToDbColumn = (refField: string): string =>
|
||||
refField.replace(/Ref$/, "Id");
|
||||
|
||||
/**
|
||||
* Group FK reference fields.
|
||||
*/
|
||||
export const GROUP_REF_FIELDS = {
|
||||
ownerRef: "users",
|
||||
homeBoardRef: "boards",
|
||||
mobileHomeBoardRef: "boards",
|
||||
} as const;
|
||||
@@ -0,0 +1,215 @@
|
||||
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`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
|
||||
import { loadBoardGraphAsync } from "./load-board-graph";
|
||||
import { serializeBoardToBundleAsync } from "./serialize-board";
|
||||
import { bundleFilenameForBoard, bundleToJson } from "./to-json";
|
||||
|
||||
export const exportBoardBundleAsync = async (db: Database, boardId: string, homarrVersion: string) => {
|
||||
const board = await loadBoardGraphAsync(db, boardId);
|
||||
if (!board) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bundle = await serializeBoardToBundleAsync(db, board, homarrVersion);
|
||||
return {
|
||||
bundle,
|
||||
filename: bundleFilenameForBoard(board.name),
|
||||
content: bundleToJson(bundle),
|
||||
};
|
||||
};
|
||||
|
||||
export { loadBoardGraphAsync } from "./load-board-graph";
|
||||
export { serializeBoardToBundleAsync } from "./serialize-board";
|
||||
export { bundleToJson, bundleFilenameForBoard } from "./to-json";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { eq } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
|
||||
export const loadBoardGraphAsync = async (db: Database, boardId: string) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
with: {
|
||||
layouts: true,
|
||||
sections: {
|
||||
with: {
|
||||
layouts: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
with: {
|
||||
layouts: true,
|
||||
integrations: {
|
||||
with: {
|
||||
integration: {
|
||||
with: {
|
||||
secrets: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return board;
|
||||
};
|
||||
|
||||
export type LoadedBoardGraph = NonNullable<Awaited<ReturnType<typeof loadBoardGraphAsync>>>;
|
||||
@@ -0,0 +1,42 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { inArray } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { HomarrBundle, HomarrBundleApp, HomarrBundleIntegration } from "../schema";
|
||||
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
|
||||
import type { LoadedBoardGraph } from "./load-board-graph";
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { HomarrBundle } from "../schema";
|
||||
|
||||
export const bundleToJson = (bundle: HomarrBundle) => {
|
||||
return JSON.stringify(bundle, null, 2);
|
||||
};
|
||||
|
||||
export const bundleFilenameForBoard = (boardName: string) => {
|
||||
const sanitized = boardName.replace(/[^A-Za-z0-9-_]/g, "-");
|
||||
return `homarr-board-${sanitized}.json`;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { handleTransactionsAsync } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
|
||||
import { parseServicesYaml } from "./parse-services-yaml";
|
||||
import { prepareHomepageImport } from "./prepare-homepage-import";
|
||||
|
||||
export type HomepageImportReport = {
|
||||
boardId: string;
|
||||
created: {
|
||||
apps: number;
|
||||
integrations: number;
|
||||
items: number;
|
||||
sections: number;
|
||||
};
|
||||
warnings: string[];
|
||||
unmappedWidgetTypes: string[];
|
||||
};
|
||||
|
||||
export const importHomepageServicesAsync = async (
|
||||
db: Database,
|
||||
content: string,
|
||||
boardName: string,
|
||||
creatorId: string,
|
||||
createIntegrations: boolean,
|
||||
): Promise<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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
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";
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseServicesYaml } from "./parse-services-yaml";
|
||||
|
||||
const sampleYaml = `
|
||||
- Media:
|
||||
- Sonarr:
|
||||
icon: sonarr.png
|
||||
href: http://sonarr.host/
|
||||
description: Series management
|
||||
ping: sonarr.host
|
||||
widget:
|
||||
type: sonarr
|
||||
url: http://sonarr.host
|
||||
key: apikeyhere
|
||||
- Radarr:
|
||||
icon: radarr.png
|
||||
href: http://radarr.host/
|
||||
widgets:
|
||||
- type: radarr
|
||||
url: http://radarr.host
|
||||
key: radarrkey
|
||||
- Downloads:
|
||||
- qBittorrent:
|
||||
icon: qbittorrent.png
|
||||
href: http://qbit.host/
|
||||
widget:
|
||||
type: qbittorrent
|
||||
url: http://qbit.host
|
||||
username: admin
|
||||
password: pass123
|
||||
`;
|
||||
|
||||
describe("parseServicesYaml", () => {
|
||||
test("parses groups, services, singular widget, and plural widgets", () => {
|
||||
const result = parseServicesYaml(sampleYaml);
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.services).toHaveLength(3);
|
||||
|
||||
const sonarr = result.services.find((service) => service.name === "Sonarr");
|
||||
expect(sonarr).toMatchObject({
|
||||
group: "Media",
|
||||
href: "http://sonarr.host/",
|
||||
ping: "sonarr.host",
|
||||
});
|
||||
expect(sonarr?.widgets).toHaveLength(1);
|
||||
expect(sonarr?.widgets[0]).toMatchObject({ type: "sonarr", key: "apikeyhere" });
|
||||
|
||||
const radarr = result.services.find((service) => service.name === "Radarr");
|
||||
expect(radarr?.widgets).toHaveLength(1);
|
||||
expect(radarr?.widgets[0]?.type).toBe("radarr");
|
||||
|
||||
const qbit = result.services.find((service) => service.name === "qBittorrent");
|
||||
expect(qbit?.group).toBe("Downloads");
|
||||
expect(qbit?.widgets[0]).toMatchObject({ type: "qbittorrent", 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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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, "") };
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
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(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface HomepageService {
|
||||
group: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
href?: string;
|
||||
description?: string;
|
||||
ping?: string;
|
||||
siteMonitor?: string;
|
||||
widgets: HomepageWidget[];
|
||||
}
|
||||
|
||||
export interface HomepageWidget {
|
||||
type: string;
|
||||
url?: string;
|
||||
key?: string;
|
||||
fields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ParseServicesYamlResult =
|
||||
| { success: true; services: HomepageService[] }
|
||||
| { success: false; error: string };
|
||||
@@ -0,0 +1,38 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { parseBundleJson } from "./parse-bundle";
|
||||
export { prepareBoardImport, type PreparedBundleImport } from "./prepare-board";
|
||||
export { importBoardBundleAsync, type BundleImportReport } from "./insert";
|
||||
export { doAppsMatch } from "./match-apps";
|
||||
@@ -0,0 +1,79 @@
|
||||
import { handleTransactionsAsync } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
|
||||
import type { HomarrBundle } from "../schema";
|
||||
import type { PreparedBundleImport } from "./prepare-board";
|
||||
import { prepareBoardImport } from "./prepare-board";
|
||||
|
||||
export type BundleImportReport = {
|
||||
created: {
|
||||
apps: number;
|
||||
items: number;
|
||||
};
|
||||
warnings: string[];
|
||||
boardId: string;
|
||||
};
|
||||
|
||||
export const importBoardBundleAsync = async (
|
||||
db: Database,
|
||||
bundle: HomarrBundle,
|
||||
boardName: string,
|
||||
creatorId: string,
|
||||
existingApps: Awaited<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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Database, InferSelectModel } from "@homarr/db";
|
||||
import type { groupMembers, groups, searchEngines, serverSettings, users } from "@homarr/db/schema";
|
||||
import { groupMembers as groupMembersTable, groups as groupsTable, searchEngines as searchEnginesTable, serverSettings as serverSettingsTable, users as usersTable } from "@homarr/db/schema";
|
||||
|
||||
export type ExistingEntities = {
|
||||
apps: Awaited<ReturnType<Database["query"]["apps"]["findMany"]>>;
|
||||
integrations: Awaited<ReturnType<Database["query"]["integrations"]["findMany"]>>;
|
||||
boards: Awaited<ReturnType<Database["query"]["boards"]["findMany"]>>;
|
||||
groups: InferSelectModel<typeof groups>[];
|
||||
searchEngines: InferSelectModel<typeof searchEngines>[];
|
||||
users: InferSelectModel<typeof users>[];
|
||||
groupMembers: InferSelectModel<typeof groupMembers>[];
|
||||
serverSettings: InferSelectModel<typeof serverSettings>[];
|
||||
};
|
||||
|
||||
export const loadExistingEntitiesAsync = async (db: Database): Promise<ExistingEntities> => {
|
||||
const [appsResult, integrationsResult, boardsResult, groupsResult, searchEnginesResult, usersResult, groupMembersResult, serverSettingsResult] =
|
||||
await Promise.all([
|
||||
db.query.apps.findMany(),
|
||||
db.query.integrations.findMany(),
|
||||
db.query.boards.findMany(),
|
||||
db.select().from(groupsTable),
|
||||
db.select().from(searchEnginesTable),
|
||||
db.select().from(usersTable),
|
||||
db.select().from(groupMembersTable),
|
||||
db.select().from(serverSettingsTable),
|
||||
]);
|
||||
|
||||
return {
|
||||
apps: appsResult,
|
||||
integrations: integrationsResult,
|
||||
boards: boardsResult,
|
||||
groups: groupsResult,
|
||||
searchEngines: searchEnginesResult,
|
||||
users: usersResult,
|
||||
groupMembers: groupMembersResult,
|
||||
serverSettings: serverSettingsResult,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { HomarrBundle } from "../schema";
|
||||
import { homarrBundleSchema } from "../schema";
|
||||
|
||||
export const parseBundleJson = (content: string): HomarrBundle => {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
return homarrBundleSchema.parse(parsed);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
export type EntityResolverDef<TBundle, TExisting extends { id: string }, TInsert> = {
|
||||
getRef: (bundle: TBundle) => string;
|
||||
match: (bundle: TBundle, existing: TExisting) => boolean;
|
||||
toInsertRow: (bundle: TBundle, newId: string) => TInsert;
|
||||
skipMessage?: (bundle: TBundle) => string;
|
||||
};
|
||||
|
||||
export type ResolveResult<TInsert> = {
|
||||
refToId: Map<string, string>;
|
||||
rows: TInsert[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export const resolveEntities = <TBundle, TExisting extends { id: string }, TInsert>(
|
||||
bundleEntities: TBundle[],
|
||||
existingEntities: TExisting[],
|
||||
def: EntityResolverDef<TBundle, TExisting, TInsert>,
|
||||
): ResolveResult<TInsert> => {
|
||||
const refToId = new Map<string, string>();
|
||||
const rows: TInsert[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const bundleEntity of bundleEntities) {
|
||||
const existing = existingEntities.find((e) => def.match(bundleEntity, e));
|
||||
|
||||
if (existing) {
|
||||
refToId.set(def.getRef(bundleEntity), existing.id);
|
||||
if (def.skipMessage) warnings.push(def.skipMessage(bundleEntity));
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = createId();
|
||||
refToId.set(def.getRef(bundleEntity), newId);
|
||||
rows.push(def.toInsertRow(bundleEntity, newId));
|
||||
}
|
||||
|
||||
return { refToId, rows, warnings };
|
||||
};
|
||||
|
||||
export const groupByKey = <T, K extends string | number>(
|
||||
items: T[],
|
||||
getKey: (item: T) => K,
|
||||
): Map<K, T[]> => {
|
||||
const map = new Map<K, T[]>();
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(item);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
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>;
|
||||
@@ -0,0 +1,48 @@
|
||||
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[];
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"dependencies": {
|
||||
"@drizzle-team/brocli": "catalog:",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/board-portability": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { command, string } from "@drizzle-team/brocli";
|
||||
|
||||
import { exportBoardBundleAsync, bundleFilenameForBoard } from "@homarr/board-portability";
|
||||
import { db } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
|
||||
export const boardsExport = command({
|
||||
name: "export",
|
||||
desc: "Export a board as a portable JSON bundle",
|
||||
options: {
|
||||
name: string("name").desc("Name of the board to export").required(),
|
||||
output: string("output").alias("o").desc("Output file path (defaults to homarr-board-<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}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { command } from "@drizzle-team/brocli";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
export const boardsList = command({
|
||||
name: "list",
|
||||
desc: "List all boards (id, name, public)",
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
handler: async () => {
|
||||
const all = await db.query.boards.findMany();
|
||||
if (all.length === 0) {
|
||||
console.log("No boards found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("ID\t\t\t\tName\t\tPublic");
|
||||
for (const board of all) {
|
||||
console.log(`${board.id}\t${board.name}\t\t${board.isPublic ? "yes" : "no"}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { command } from "@drizzle-team/brocli";
|
||||
|
||||
import { boardsExport } from "./boards-export";
|
||||
import { boardsList } from "./boards-list";
|
||||
|
||||
export const boardsRoot = command({
|
||||
name: "boards",
|
||||
desc: "Group of commands to manage boards",
|
||||
subcommands: [boardsList, boardsExport],
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { run } from "@drizzle-team/brocli";
|
||||
|
||||
import { boardsRoot } from "./commands/boards";
|
||||
import { fixUsernames } from "./commands/fix-usernames";
|
||||
import { integrationsRoot } from "./commands/integrations";
|
||||
import { recreateAdmin } from "./commands/recreate-admin";
|
||||
import { resetPassword } from "./commands/reset-password";
|
||||
import { usersRoot } from "./commands/users";
|
||||
|
||||
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
|
||||
const commands = [usersRoot, integrationsRoot, boardsRoot, resetPassword, fixUsernames, recreateAdmin];
|
||||
|
||||
void run(commands, {
|
||||
name: "homarr-cli",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@homarr/cron-jobs-core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/icons": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `docker_app_source` (
|
||||
`host` varchar(512) NOT NULL,
|
||||
`container_id` varchar(128) NOT NULL,
|
||||
`external_id` varchar(512) NOT NULL,
|
||||
`app_id` varchar(64) NOT NULL,
|
||||
`board_id` varchar(64) NOT NULL,
|
||||
`item_id` varchar(64),
|
||||
`integration_id` varchar(64),
|
||||
`updated_at` timestamp NOT NULL,
|
||||
CONSTRAINT `docker_app_source_host_container_id_pk` PRIMARY KEY(`host`,`container_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -281,6 +281,13 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "docker_app_source" (
|
||||
"host" varchar(512) NOT NULL,
|
||||
"container_id" varchar(128) NOT NULL,
|
||||
"external_id" varchar(512) NOT NULL,
|
||||
"app_id" varchar(64) NOT NULL,
|
||||
"board_id" varchar(64) NOT NULL,
|
||||
"item_id" varchar(64),
|
||||
"integration_id" varchar(64),
|
||||
"updated_at" timestamp NOT NULL,
|
||||
CONSTRAINT "docker_app_source_host_container_id_pk" PRIMARY KEY("host","container_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "docker_app_source__external_id_idx" ON "docker_app_source" USING btree ("host","external_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,13 @@
|
||||
"when": 1779199097716,
|
||||
"tag": "0005_add_user_onboarding_tour_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1780490693344,
|
||||
"tag": "0006_add_docker_app_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `docker_app_source` (
|
||||
`host` text NOT NULL,
|
||||
`container_id` text NOT NULL,
|
||||
`external_id` text NOT NULL,
|
||||
`app_id` text NOT NULL,
|
||||
`board_id` text NOT NULL,
|
||||
`item_id` text,
|
||||
`integration_id` text,
|
||||
`updated_at` integer NOT NULL,
|
||||
PRIMARY KEY(`host`, `container_id`),
|
||||
FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -267,6 +267,13 @@
|
||||
"when": 1779199046805,
|
||||
"tag": "0037_add_user_onboarding_tour_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "6",
|
||||
"when": 1780490686287,
|
||||
"tag": "0038_add_docker_app_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export const {
|
||||
sectionLayouts,
|
||||
trustedCertificateHostnames,
|
||||
cronJobConfigurations,
|
||||
dockerAppSources,
|
||||
} = schema;
|
||||
|
||||
export type User = InferSelectModel<typeof schema.users>;
|
||||
|
||||
@@ -504,6 +504,33 @@ export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
|
||||
isEnabled: boolean().default(true).notNull(),
|
||||
});
|
||||
|
||||
export const dockerAppSources = mysqlTable(
|
||||
"docker_app_source",
|
||||
{
|
||||
host: varchar({ length: 512 }).notNull(),
|
||||
containerId: varchar({ length: 128 }).notNull(),
|
||||
externalId: varchar({ length: 512 }).notNull(),
|
||||
appId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => apps.id, { onDelete: "cascade" }),
|
||||
boardId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
|
||||
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
|
||||
updatedAt: timestamp()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date())
|
||||
.$defaultFn(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.host, table.containerId],
|
||||
}),
|
||||
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
@@ -768,3 +795,22 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
|
||||
references: [boards.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
|
||||
app: one(apps, {
|
||||
fields: [dockerAppSources.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [dockerAppSources.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [dockerAppSources.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
integration: one(integrations, {
|
||||
fields: [dockerAppSources.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
}));
|
||||
@@ -503,6 +503,33 @@ export const cronJobConfigurations = pgTable("cron_job_configuration", {
|
||||
isEnabled: boolean().default(true).notNull(),
|
||||
});
|
||||
|
||||
export const dockerAppSources = pgTable(
|
||||
"docker_app_source",
|
||||
{
|
||||
host: varchar({ length: 512 }).notNull(),
|
||||
containerId: varchar({ length: 128 }).notNull(),
|
||||
externalId: varchar({ length: 512 }).notNull(),
|
||||
appId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => apps.id, { onDelete: "cascade" }),
|
||||
boardId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
|
||||
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
|
||||
updatedAt: timestamp()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date())
|
||||
.$defaultFn(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.host, table.containerId],
|
||||
}),
|
||||
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
@@ -767,3 +794,22 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
|
||||
references: [boards.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
|
||||
app: one(apps, {
|
||||
fields: [dockerAppSources.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [dockerAppSources.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [dockerAppSources.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
integration: one(integrations, {
|
||||
fields: [dockerAppSources.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
}));
|
||||
@@ -489,6 +489,33 @@ export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
|
||||
isEnabled: int({ mode: "boolean" }).default(true).notNull(),
|
||||
});
|
||||
|
||||
export const dockerAppSources = sqliteTable(
|
||||
"docker_app_source",
|
||||
{
|
||||
host: text().notNull(),
|
||||
containerId: text().notNull(),
|
||||
externalId: text().notNull(),
|
||||
appId: text()
|
||||
.notNull()
|
||||
.references(() => apps.id, { onDelete: "cascade" }),
|
||||
boardId: text()
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
itemId: text().references(() => items.id, { onDelete: "set null" }),
|
||||
integrationId: text().references(() => integrations.id, { onDelete: "set null" }),
|
||||
updatedAt: int({ mode: "timestamp" })
|
||||
.$onUpdateFn(() => new Date())
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.host, table.containerId],
|
||||
}),
|
||||
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
@@ -753,3 +780,22 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
|
||||
references: [boards.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
|
||||
app: one(apps, {
|
||||
fields: [dockerAppSources.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
board: one(boards, {
|
||||
fields: [dockerAppSources.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [dockerAppSources.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
integration: one(integrations, {
|
||||
fields: [dockerAppSources.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IntegrationKind } from "./integration";
|
||||
import type { IntegrationSecretKind } from "./integration";
|
||||
import type { WidgetKind } from "./widget";
|
||||
|
||||
export interface HomepageWidgetMapping {
|
||||
integrationKind: IntegrationKind;
|
||||
widgetKind: WidgetKind;
|
||||
secretFieldMap: Record<string, IntegrationSecretKind>;
|
||||
}
|
||||
|
||||
export const homepageWidgetMap: Record<string, HomepageWidgetMapping | null> = {
|
||||
sonarr: { integrationKind: "sonarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
|
||||
radarr: { integrationKind: "radarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
|
||||
lidarr: { integrationKind: "lidarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
|
||||
readarr: { integrationKind: "readarr", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
|
||||
prowlarr: { integrationKind: "prowlarr", widgetKind: "indexerManager", secretFieldMap: { key: "apiKey" } },
|
||||
bazarr: null,
|
||||
jellyfin: { integrationKind: "jellyfin", widgetKind: "mediaServer", secretFieldMap: { key: "apiKey" } },
|
||||
emby: { integrationKind: "emby", widgetKind: "mediaServer", secretFieldMap: { key: "apiKey" } },
|
||||
plex: { integrationKind: "plex", widgetKind: "mediaServer", secretFieldMap: { key: "apiKey" } },
|
||||
jellyseerr: {
|
||||
integrationKind: "jellyseerr",
|
||||
widgetKind: "mediaRequests-requestList",
|
||||
secretFieldMap: { key: "apiKey" },
|
||||
},
|
||||
overseerr: {
|
||||
integrationKind: "overseerr",
|
||||
widgetKind: "mediaRequests-requestList",
|
||||
secretFieldMap: { key: "apiKey" },
|
||||
},
|
||||
tautulli: null,
|
||||
sabnzbd: { integrationKind: "sabNzbd", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
|
||||
nzbget: { integrationKind: "nzbGet", widgetKind: "downloads", secretFieldMap: { key: "apiKey" } },
|
||||
deluge: { integrationKind: "deluge", widgetKind: "downloads", secretFieldMap: { password: "password" } },
|
||||
transmission: {
|
||||
integrationKind: "transmission",
|
||||
widgetKind: "downloads",
|
||||
secretFieldMap: { username: "username", password: "password" },
|
||||
},
|
||||
qbittorrent: {
|
||||
integrationKind: "qBittorrent",
|
||||
widgetKind: "downloads",
|
||||
secretFieldMap: { username: "username", password: "password" },
|
||||
},
|
||||
pihole: { integrationKind: "piHole", widgetKind: "dnsHoleSummary", secretFieldMap: { key: "apiKey" } },
|
||||
adguard: {
|
||||
integrationKind: "adGuardHome",
|
||||
widgetKind: "dnsHoleSummary",
|
||||
secretFieldMap: { username: "username", password: "password" },
|
||||
},
|
||||
homeassistant: {
|
||||
integrationKind: "homeAssistant",
|
||||
widgetKind: "smartHome-entityState",
|
||||
secretFieldMap: { key: "apiKey" },
|
||||
},
|
||||
proxmox: { integrationKind: "proxmox", widgetKind: "healthMonitoring", secretFieldMap: {} },
|
||||
};
|
||||
@@ -17,3 +17,4 @@ export * from "./emptysuperjson";
|
||||
export * from "./hotkeys";
|
||||
export * from "./docker-integration-match";
|
||||
export * from "./integration-url-template";
|
||||
export * from "./homepage-widget-map";
|
||||
@@ -25,11 +25,15 @@
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"dockerode": "catalog:"
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"dockerode": "catalog:",
|
||||
"superjson": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import type { ContainerInfo } from "dockerode";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { dockerLabels, homepageLabels } from "../../labels";
|
||||
import { parseContainerLabels } from "../parse-container-labels";
|
||||
|
||||
const createContainer = (labels: Record<string, string>, id = "container-abc123"): Pick<ContainerInfo, "Id" | "Labels"> => ({
|
||||
Id: id,
|
||||
Labels: labels,
|
||||
});
|
||||
|
||||
describe("parseContainerLabels", () => {
|
||||
describe("homarr native labels", () => {
|
||||
test("parses all homarr labels into DiscoveredService", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Jellyfin",
|
||||
[dockerLabels.href]: "https://jellyfin.local",
|
||||
[dockerLabels.icon]: "https://cdn.example/icon.png",
|
||||
[dockerLabels.description]: "Media server",
|
||||
[dockerLabels.ping]: "https://jellyfin.local/health",
|
||||
[dockerLabels.id]: "jellyfin-1",
|
||||
[dockerLabels.board]: "homelab",
|
||||
[dockerLabels.integration]: "jellyfin",
|
||||
[dockerLabels.widget]: "mediaServer",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "socket",
|
||||
group: "Media",
|
||||
name: "Jellyfin",
|
||||
href: "https://jellyfin.local",
|
||||
icon: "https://cdn.example/icon.png",
|
||||
description: "Media server",
|
||||
pingUrl: "https://jellyfin.local/health",
|
||||
externalId: "jellyfin-1",
|
||||
boardName: "homelab",
|
||||
integrationKind: "jellyfin",
|
||||
widgetKind: "mediaServer",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses minimal required homarr labels (name, group, href)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Networking",
|
||||
[dockerLabels.name]: "Pi-hole",
|
||||
[dockerLabels.href]: "http://pihole:80/admin",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "socket",
|
||||
group: "Networking",
|
||||
name: "Pi-hole",
|
||||
href: "http://pihole:80/admin",
|
||||
externalId: "container-abc123",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses container ID as externalId when homarr.id is absent", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer(
|
||||
{
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Plex",
|
||||
[dockerLabels.href]: "http://plex:32400",
|
||||
},
|
||||
"sha256:deadbeef1234",
|
||||
),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.externalId).toBe("sha256:deadbeef1234");
|
||||
});
|
||||
|
||||
test("uses homarr.id as externalId when present", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Plex",
|
||||
[dockerLabels.href]: "http://plex:32400",
|
||||
[dockerLabels.id]: "plex-server-main",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.externalId).toBe("plex-server-main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("homepage fallback labels", () => {
|
||||
test("uses homepage labels when homarr.name is absent", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Downloads",
|
||||
[homepageLabels.name]: "Sonarr",
|
||||
[homepageLabels.href]: "https://sonarr.local",
|
||||
[homepageLabels.icon]: "https://cdn.example/sonarr.png",
|
||||
[homepageLabels.description]: "TV shows",
|
||||
}),
|
||||
"192.168.1.10:2375",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "192.168.1.10:2375",
|
||||
group: "Downloads",
|
||||
name: "Sonarr",
|
||||
href: "https://sonarr.local",
|
||||
icon: "https://cdn.example/sonarr.png",
|
||||
description: "TV shows",
|
||||
externalId: "container-abc123",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not use homepage fallback when readHomepageLabels is false", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Downloads",
|
||||
[homepageLabels.name]: "Sonarr",
|
||||
[homepageLabels.href]: "https://sonarr.local",
|
||||
}),
|
||||
"socket",
|
||||
{ readHomepageLabels: false },
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("homepage labels have no ping, board, integration, or widget support", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Media",
|
||||
[homepageLabels.name]: "Emby",
|
||||
[homepageLabels.href]: "http://emby:8096",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.pingUrl).toBeUndefined();
|
||||
expect(result?.boardName).toBeUndefined();
|
||||
expect(result?.integrationKind).toBeUndefined();
|
||||
expect(result?.widgetKind).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("label priority and mixing", () => {
|
||||
test("prefers homarr labels over homepage when both are present", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Homarr Group",
|
||||
[dockerLabels.name]: "Homarr Name",
|
||||
[dockerLabels.href]: "https://homarr.local",
|
||||
[homepageLabels.group]: "Homepage Group",
|
||||
[homepageLabels.name]: "Homepage Name",
|
||||
[homepageLabels.href]: "https://homepage.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.group).toBe("Homarr Group");
|
||||
expect(result?.name).toBe("Homarr Name");
|
||||
expect(result?.href).toBe("https://homarr.local");
|
||||
});
|
||||
|
||||
test("disables homepage fallback entirely when homarr.name is present", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.name]: "MyApp",
|
||||
[dockerLabels.href]: "http://myapp:3000",
|
||||
[homepageLabels.group]: "FromHomepage",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("uses homarr icon with homepage name/group/href when homarr.name absent but homarr.icon present", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Media",
|
||||
[homepageLabels.name]: "Jellyfin",
|
||||
[homepageLabels.href]: "http://jellyfin:8096",
|
||||
[dockerLabels.icon]: "https://custom-icon.example/jf.png",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.name).toBe("Jellyfin");
|
||||
expect(result?.icon).toBe("https://custom-icon.example/jf.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("homarr.hide", () => {
|
||||
test("skips container when homarr.hide is present with value 'true'", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.hide]: "true",
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Hidden",
|
||||
[dockerLabels.href]: "https://hidden.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("skips container when homarr.hide is present with empty value", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.hide]: "",
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Hidden",
|
||||
[dockerLabels.href]: "https://hidden.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("skips container when homarr.hide is any string (key presence is enough)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.hide]: "anything",
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Hidden",
|
||||
[dockerLabels.href]: "https://hidden.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing required labels", () => {
|
||||
test("returns null when group is missing", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.name]: "NoGroup",
|
||||
[dockerLabels.href]: "https://nogroup.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when name is missing", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.href]: "https://noname.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when href is missing", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "NoHref",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when homepage labels are incomplete (missing group)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.name]: "Incomplete",
|
||||
[homepageLabels.href]: "http://incomplete.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespace handling", () => {
|
||||
test("trims whitespace from all label values", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: " Media ",
|
||||
[dockerLabels.name]: " Jellyfin ",
|
||||
[dockerLabels.href]: " https://jellyfin.local ",
|
||||
[dockerLabels.icon]: " https://icon.png ",
|
||||
[dockerLabels.description]: " A media server ",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.group).toBe("Media");
|
||||
expect(result?.name).toBe("Jellyfin");
|
||||
expect(result?.href).toBe("https://jellyfin.local");
|
||||
expect(result?.icon).toBe("https://icon.png");
|
||||
expect(result?.description).toBe("A media server");
|
||||
});
|
||||
|
||||
test("treats whitespace-only values as empty (missing)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: " ",
|
||||
[dockerLabels.href]: "https://example.local",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration and widget kind validation", () => {
|
||||
test("parses valid integration kind", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Radarr",
|
||||
[dockerLabels.href]: "http://radarr:7878",
|
||||
[dockerLabels.integration]: "radarr",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.integrationKind).toBe("radarr");
|
||||
});
|
||||
|
||||
test("ignores invalid integration kind", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Custom App",
|
||||
[dockerLabels.href]: "http://custom:3000",
|
||||
[dockerLabels.integration]: "nonExistentIntegration",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.integrationKind).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses valid widget kind", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Jellyfin",
|
||||
[dockerLabels.href]: "http://jellyfin:8096",
|
||||
[dockerLabels.widget]: "mediaServer",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.widgetKind).toBe("mediaServer");
|
||||
});
|
||||
|
||||
test("ignores invalid widget kind", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "Custom",
|
||||
[dockerLabels.href]: "http://custom:3000",
|
||||
[dockerLabels.widget]: "totallyFakeWidget",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.widgetKind).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles empty integration kind gracefully", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Media",
|
||||
[dockerLabels.name]: "App",
|
||||
[dockerLabels.href]: "http://app:3000",
|
||||
[dockerLabels.integration]: "",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.integrationKind).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
test("homepage-only service (common migration use case)", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[homepageLabels.group]: "Monitoring",
|
||||
[homepageLabels.name]: "Grafana",
|
||||
[homepageLabels.href]: "http://grafana:3000",
|
||||
[homepageLabels.icon]: "grafana",
|
||||
[homepageLabels.description]: "Metrics and dashboards",
|
||||
}),
|
||||
"tcp://192.168.1.50:2375",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
containerId: "container-abc123",
|
||||
host: "tcp://192.168.1.50:2375",
|
||||
group: "Monitoring",
|
||||
name: "Grafana",
|
||||
href: "http://grafana:3000",
|
||||
icon: "grafana",
|
||||
description: "Metrics and dashboards",
|
||||
externalId: "container-abc123",
|
||||
});
|
||||
});
|
||||
|
||||
test("service targeting a specific board", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer({
|
||||
[dockerLabels.group]: "Infrastructure",
|
||||
[dockerLabels.name]: "Traefik",
|
||||
[dockerLabels.href]: "http://traefik:8080",
|
||||
[dockerLabels.board]: "networking-board",
|
||||
[dockerLabels.icon]: "https://cdn.example/traefik.svg",
|
||||
}),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.boardName).toBe("networking-board");
|
||||
});
|
||||
|
||||
test("service with all optional fields empty uses defaults", () => {
|
||||
const result = parseContainerLabels(
|
||||
createContainer(
|
||||
{
|
||||
[dockerLabels.group]: "Tools",
|
||||
[dockerLabels.name]: "Portainer",
|
||||
[dockerLabels.href]: "http://portainer:9000",
|
||||
},
|
||||
"abc123def456",
|
||||
),
|
||||
"socket",
|
||||
);
|
||||
|
||||
expect(result?.icon).toBeUndefined();
|
||||
expect(result?.description).toBeUndefined();
|
||||
expect(result?.pingUrl).toBeUndefined();
|
||||
expect(result?.boardName).toBeUndefined();
|
||||
expect(result?.integrationKind).toBeUndefined();
|
||||
expect(result?.widgetKind).toBeUndefined();
|
||||
expect(result?.externalId).toBe("abc123def456");
|
||||
});
|
||||
|
||||
test("container with no labels returns null", () => {
|
||||
const result = parseContainerLabels(createContainer({}), "socket");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export type { DiscoveredService } from "./types";
|
||||
export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
|
||||
export { listDiscoveredContainersAsync } from "./list-discovered-containers";
|
||||
export {
|
||||
syncDiscoveredServicesAsync,
|
||||
type SyncDiscoveredServicesOptions,
|
||||
type SyncDiscoveredServicesResult,
|
||||
} from "./sync-discovered-services";
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
import { dockerLabels, homepageLabels } from "../labels";
|
||||
import { DockerSingleton } from "../singleton";
|
||||
import type { ParseContainerLabelsOptions } from "./parse-container-labels";
|
||||
import { parseContainerLabels } from "./parse-container-labels";
|
||||
import type { DiscoveredService } from "./types";
|
||||
|
||||
const logger = createLogger({ module: "dockerDiscovery" });
|
||||
|
||||
const hasDiscoveryLabels = (labels: Record<string, string>) =>
|
||||
Object.values(dockerLabels).some((label) => label !== dockerLabels.hide && label in labels) ||
|
||||
Object.values(homepageLabels).some((label) => label in labels);
|
||||
|
||||
export const listDiscoveredContainersAsync = async (
|
||||
options: ParseContainerLabelsOptions = {},
|
||||
): Promise<DiscoveredService[]> => {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const results = await Promise.allSettled(
|
||||
dockerInstances.map(async ({ instance, host }) => {
|
||||
const containers = await instance.listContainers({ all: true });
|
||||
return containers
|
||||
.filter((container) => hasDiscoveryLabels(container.Labels ?? {}))
|
||||
.map((container) => parseContainerLabels(container, host, options))
|
||||
.filter((service): service is DiscoveredService => service !== null);
|
||||
}),
|
||||
);
|
||||
|
||||
return results.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
return result.value;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to list containers for discovery",
|
||||
{ host: dockerInstances[index]?.host },
|
||||
{ cause: result.reason },
|
||||
),
|
||||
);
|
||||
|
||||
return [];
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ContainerInfo } from "dockerode";
|
||||
|
||||
import { integrationKinds, widgetKinds } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { dockerLabels, homepageLabels } from "../labels";
|
||||
import type { DiscoveredService } from "./types";
|
||||
|
||||
export interface ParseContainerLabelsOptions {
|
||||
readHomepageLabels?: boolean;
|
||||
}
|
||||
|
||||
const homarrLabelKeys = {
|
||||
group: dockerLabels.group,
|
||||
name: dockerLabels.name,
|
||||
href: dockerLabels.href,
|
||||
icon: dockerLabels.icon,
|
||||
description: dockerLabels.description,
|
||||
ping: dockerLabels.ping,
|
||||
id: dockerLabels.id,
|
||||
board: dockerLabels.board,
|
||||
integration: dockerLabels.integration,
|
||||
widget: dockerLabels.widget,
|
||||
} as const;
|
||||
|
||||
const homepageLabelKeys = {
|
||||
group: homepageLabels.group,
|
||||
name: homepageLabels.name,
|
||||
href: homepageLabels.href,
|
||||
icon: homepageLabels.icon,
|
||||
description: homepageLabels.description,
|
||||
} as const;
|
||||
|
||||
const integrationKindSet = new Set<string>(integrationKinds);
|
||||
const widgetKindSet = new Set<string>(widgetKinds);
|
||||
|
||||
const readLabel = (labels: Record<string, string>, key: string) => labels[key]?.trim();
|
||||
|
||||
const resolveLabelValue = (
|
||||
labels: Record<string, string>,
|
||||
homarrKey: string,
|
||||
homepageKey: string,
|
||||
useHomepageFallback: boolean,
|
||||
) => readLabel(labels, homarrKey) ?? (useHomepageFallback ? readLabel(labels, homepageKey) : undefined);
|
||||
|
||||
export const parseContainerLabels = (
|
||||
container: Pick<ContainerInfo, "Id" | "Labels">,
|
||||
host: string,
|
||||
options: ParseContainerLabelsOptions = {},
|
||||
): DiscoveredService | null => {
|
||||
const labels = container.Labels ?? {};
|
||||
|
||||
if (dockerLabels.hide in labels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const readHomepageLabels = options.readHomepageLabels ?? true;
|
||||
const hasHomarrName = Boolean(readLabel(labels, dockerLabels.name));
|
||||
const useHomepageFallback = readHomepageLabels && !hasHomarrName;
|
||||
|
||||
const name = resolveLabelValue(labels, homarrLabelKeys.name, homepageLabelKeys.name, useHomepageFallback);
|
||||
const href = resolveLabelValue(labels, homarrLabelKeys.href, homepageLabelKeys.href, useHomepageFallback);
|
||||
const group = resolveLabelValue(labels, homarrLabelKeys.group, homepageLabelKeys.group, useHomepageFallback);
|
||||
|
||||
if (!name || !href || !group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon =
|
||||
readLabel(labels, homarrLabelKeys.icon) ??
|
||||
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.icon) : undefined);
|
||||
const description =
|
||||
readLabel(labels, homarrLabelKeys.description) ??
|
||||
(useHomepageFallback ? readLabel(labels, homepageLabelKeys.description) : undefined);
|
||||
const pingUrl = readLabel(labels, homarrLabelKeys.ping);
|
||||
const boardName = readLabel(labels, homarrLabelKeys.board);
|
||||
const externalId = readLabel(labels, homarrLabelKeys.id) ?? container.Id;
|
||||
|
||||
const integrationKind = parseIntegrationKind(readLabel(labels, homarrLabelKeys.integration));
|
||||
const widgetKind = parseWidgetKind(readLabel(labels, homarrLabelKeys.widget));
|
||||
|
||||
return {
|
||||
containerId: container.Id,
|
||||
host,
|
||||
group,
|
||||
name,
|
||||
href,
|
||||
icon,
|
||||
description,
|
||||
pingUrl,
|
||||
externalId,
|
||||
boardName,
|
||||
integrationKind,
|
||||
widgetKind,
|
||||
};
|
||||
};
|
||||
|
||||
const parseIntegrationKind = (value: string | undefined): IntegrationKind | undefined => {
|
||||
if (!value || !integrationKindSet.has(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as IntegrationKind;
|
||||
};
|
||||
|
||||
const parseWidgetKind = (value: string | undefined): WidgetKind | undefined => {
|
||||
if (!value || !widgetKindSet.has(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as WidgetKind;
|
||||
};
|
||||
@@ -0,0 +1,451 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { emptySuperJSON } from "@homarr/definitions";
|
||||
import type { InferInsertModel, InferSelectModel } from "@homarr/db";
|
||||
import { and, asc, db, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
import { apps, boards, dockerAppSources, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
|
||||
|
||||
import type { DiscoveredService } from "./types";
|
||||
|
||||
const logger = createLogger({ module: "dockerDiscoverySync" });
|
||||
|
||||
export interface SyncDiscoveredServicesOptions {
|
||||
targetBoardName?: string | null;
|
||||
enableStatusByDefault?: boolean;
|
||||
forceDisableStatus?: boolean;
|
||||
defaultItemWidth?: number;
|
||||
defaultItemHeight?: number;
|
||||
}
|
||||
|
||||
export interface SyncDiscoveredServicesResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface SyncMetadata {
|
||||
appId: string;
|
||||
itemId?: string;
|
||||
}
|
||||
|
||||
interface AppItemOptions {
|
||||
appId: string;
|
||||
openInNewTab: boolean;
|
||||
showTitle: boolean;
|
||||
descriptionDisplayMode: "hidden";
|
||||
layout: "column";
|
||||
pingEnabled: boolean;
|
||||
}
|
||||
|
||||
type AppRow = InferSelectModel<typeof apps>;
|
||||
|
||||
type BoardWithRelations = NonNullable<Awaited<ReturnType<typeof loadBoardsWithRelationsAsync>>>[number];
|
||||
|
||||
type BoardItem = BoardWithRelations["items"][number];
|
||||
|
||||
const createSyncKey = (service: DiscoveredService) => `${service.host}/${service.containerId}`;
|
||||
|
||||
const appsMatchByHref = (app: Pick<AppRow, "href">, href: string) => app.href === href;
|
||||
|
||||
export const syncDiscoveredServicesAsync = async (
|
||||
discoveredServices: DiscoveredService[],
|
||||
options: SyncDiscoveredServicesOptions = {},
|
||||
): Promise<SyncDiscoveredServicesResult> => {
|
||||
const itemWidth = options.defaultItemWidth ?? 1;
|
||||
const itemHeight = options.defaultItemHeight ?? 1;
|
||||
const result: SyncDiscoveredServicesResult = { created: 0, updated: 0, skipped: 0 };
|
||||
const syncMetadata = new Map<string, SyncMetadata>();
|
||||
const existingApps = await db.query.apps.findMany();
|
||||
const existingSources = await db.query.dockerAppSources.findMany();
|
||||
const boardList = await loadBoardsWithRelationsAsync();
|
||||
|
||||
const appsToInsert: InferInsertModel<typeof apps>[] = [];
|
||||
const appsToUpdate: { id: string; values: Partial<InferInsertModel<typeof apps>> }[] = [];
|
||||
const sectionsToInsert: InferInsertModel<typeof sections>[] = [];
|
||||
const itemsToInsert: InferInsertModel<typeof items>[] = [];
|
||||
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
|
||||
const pendingSectionsByBoard = new Map<string, InferInsertModel<typeof sections>[]>();
|
||||
const sectionYOffsetByBoard = new Map<string, number>();
|
||||
const itemYOffsetBySection = new Map<string, number>();
|
||||
const pendingItemLayouts: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
|
||||
for (const service of discoveredServices) {
|
||||
const syncKey = createSyncKey(service);
|
||||
|
||||
if (syncMetadata.has(syncKey)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
|
||||
if (!board) {
|
||||
logger.warn("No target board found for discovered service", { syncKey, boardName: service.boardName });
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstLayout = getFirstLayout(board.layouts);
|
||||
if (!firstLayout) {
|
||||
logger.warn("Board has no layouts for discovered service", { boardId: board.id, syncKey });
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingSource = existingSources.find(
|
||||
(source) => source.host === service.host && source.externalId === service.externalId,
|
||||
);
|
||||
const existingApp = existingSource
|
||||
? existingApps.find((app) => app.id === existingSource.appId)
|
||||
: existingApps.find((app) => appsMatchByHref(app, service.href));
|
||||
let appId: string;
|
||||
let wasCreated = false;
|
||||
let wasUpdated = false;
|
||||
|
||||
if (existingApp) {
|
||||
appId = existingApp.id;
|
||||
const updateValues = buildAppUpdateValues(service, existingApp);
|
||||
if (updateValues) {
|
||||
appsToUpdate.push({ id: appId, values: updateValues });
|
||||
wasUpdated = true;
|
||||
Object.assign(existingApp, updateValues);
|
||||
}
|
||||
} else {
|
||||
appId = createId();
|
||||
const newApp = buildAppInsert(service, appId);
|
||||
appsToInsert.push(newApp);
|
||||
existingApps.push(newApp as AppRow);
|
||||
wasCreated = true;
|
||||
}
|
||||
|
||||
const categorySection = findOrCreateCategorySection(
|
||||
board,
|
||||
service.group,
|
||||
sectionsToInsert,
|
||||
pendingSectionsByBoard,
|
||||
sectionYOffsetByBoard,
|
||||
);
|
||||
|
||||
const existingItem = findAppItemForApp(board.items, itemsToInsert, pendingItemLayouts, appId, categorySection.id);
|
||||
|
||||
if (!existingItem) {
|
||||
const newItemId = createId();
|
||||
const yOffset = getNextItemYOffset(
|
||||
board,
|
||||
categorySection.id,
|
||||
firstLayout.id,
|
||||
itemYOffsetBySection,
|
||||
pendingItemLayouts,
|
||||
itemHeight,
|
||||
);
|
||||
|
||||
const newItemLayouts = board.layouts.map((layout) => ({
|
||||
itemId: newItemId,
|
||||
sectionId: categorySection.id,
|
||||
layoutId: layout.id,
|
||||
xOffset: 0,
|
||||
yOffset,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
}));
|
||||
|
||||
itemsToInsert.push({
|
||||
id: newItemId,
|
||||
boardId: board.id,
|
||||
kind: "app",
|
||||
options: SuperJSON.stringify(buildAppItemOptions(appId, service, options)),
|
||||
advancedOptions: SuperJSON.stringify({
|
||||
title: null,
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
}),
|
||||
});
|
||||
|
||||
itemLayoutsToInsert.push(...newItemLayouts);
|
||||
pendingItemLayouts.push(...newItemLayouts);
|
||||
syncMetadata.set(syncKey, { appId, itemId: newItemId });
|
||||
wasCreated = true;
|
||||
} else {
|
||||
syncMetadata.set(syncKey, { appId, itemId: existingItem.id });
|
||||
}
|
||||
|
||||
if (wasCreated) {
|
||||
result.created += 1;
|
||||
} else if (wasUpdated) {
|
||||
result.updated += 1;
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const { id, values } of appsToUpdate) {
|
||||
await db.update(apps).set(values).where(eq(apps.id, id));
|
||||
}
|
||||
|
||||
const sourcesToUpsert: InferInsertModel<typeof dockerAppSources>[] = [];
|
||||
for (const [syncKey, meta] of syncMetadata.entries()) {
|
||||
const service = discoveredServices.find((svc) => createSyncKey(svc) === syncKey);
|
||||
if (!service) continue;
|
||||
|
||||
const board = resolveTargetBoard(boardList, service, options.targetBoardName);
|
||||
if (!board) continue;
|
||||
|
||||
sourcesToUpsert.push({
|
||||
host: service.host,
|
||||
containerId: service.containerId,
|
||||
externalId: service.externalId,
|
||||
appId: meta.appId,
|
||||
boardId: board.id,
|
||||
itemId: meta.itemId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
appsToInsert.length === 0 &&
|
||||
sectionsToInsert.length === 0 &&
|
||||
itemsToInsert.length === 0 &&
|
||||
sourcesToUpsert.length === 0
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const insertCollection = createDbInsertCollectionForTransaction(["apps", "sections", "items", "itemLayouts"]);
|
||||
insertCollection.apps.push(...appsToInsert);
|
||||
insertCollection.sections.push(...sectionsToInsert);
|
||||
insertCollection.items.push(...itemsToInsert);
|
||||
insertCollection.itemLayouts.push(...itemLayoutsToInsert);
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(innerDb) {
|
||||
await insertCollection.insertAllAsync(innerDb);
|
||||
const table = dockerAppSources as never;
|
||||
for (const source of sourcesToUpsert) {
|
||||
const existing = await innerDb.query.dockerAppSources.findFirst({
|
||||
where: and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)),
|
||||
});
|
||||
if (existing) {
|
||||
await innerDb
|
||||
.update(table)
|
||||
.set({ appId: source.appId, boardId: source.boardId, itemId: source.itemId, externalId: source.externalId })
|
||||
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)));
|
||||
} else {
|
||||
await innerDb.insert(table).values(source as never);
|
||||
}
|
||||
}
|
||||
},
|
||||
handleSync(innerDb) {
|
||||
insertCollection.insertAll(innerDb);
|
||||
for (const source of sourcesToUpsert) {
|
||||
try {
|
||||
innerDb.insert(dockerAppSources).values(source).run();
|
||||
} catch {
|
||||
innerDb
|
||||
.update(dockerAppSources)
|
||||
.set({ appId: source.appId, boardId: source.boardId, itemId: source.itemId, externalId: source.externalId })
|
||||
.where(and(eq(dockerAppSources.host, source.host), eq(dockerAppSources.containerId, source.containerId)))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const loadBoardsWithRelationsAsync = async () =>
|
||||
db.query.boards.findMany({
|
||||
orderBy: asc(boards.name),
|
||||
with: {
|
||||
layouts: true,
|
||||
sections: true,
|
||||
items: {
|
||||
with: {
|
||||
layouts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolveTargetBoard = (
|
||||
boardList: BoardWithRelations[],
|
||||
service: DiscoveredService,
|
||||
defaultBoardName: string | null | undefined,
|
||||
) => {
|
||||
const boardName = service.boardName ?? defaultBoardName ?? undefined;
|
||||
if (boardName) {
|
||||
return boardList.find((board) => board.name === boardName) ?? boardList.at(0);
|
||||
}
|
||||
return boardList.at(0);
|
||||
};
|
||||
|
||||
const getFirstLayout = (boardLayouts: InferSelectModel<typeof layouts>[]) =>
|
||||
[...boardLayouts].sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint).at(0);
|
||||
|
||||
const buildAppInsert = (service: DiscoveredService, appId: string): InferInsertModel<typeof apps> => ({
|
||||
id: appId,
|
||||
name: service.name,
|
||||
description: service.description ?? null,
|
||||
iconUrl: service.icon ?? "",
|
||||
href: service.href,
|
||||
pingUrl: service.pingUrl ?? null,
|
||||
});
|
||||
|
||||
const buildAppUpdateValues = (
|
||||
service: DiscoveredService,
|
||||
existingApp: AppRow,
|
||||
): Partial<InferInsertModel<typeof apps>> | null => {
|
||||
const values: Partial<InferInsertModel<typeof apps>> = {};
|
||||
|
||||
if (existingApp.name !== service.name) {
|
||||
values.name = service.name;
|
||||
}
|
||||
if (existingApp.href !== service.href) {
|
||||
values.href = service.href;
|
||||
}
|
||||
if (service.icon !== undefined && existingApp.iconUrl !== service.icon) {
|
||||
values.iconUrl = service.icon;
|
||||
}
|
||||
if (service.description !== undefined && existingApp.description !== service.description) {
|
||||
values.description = service.description;
|
||||
}
|
||||
if (service.pingUrl !== undefined && existingApp.pingUrl !== service.pingUrl) {
|
||||
values.pingUrl = service.pingUrl;
|
||||
}
|
||||
|
||||
return Object.keys(values).length > 0 ? values : null;
|
||||
};
|
||||
|
||||
const buildAppItemOptions = (
|
||||
appId: string,
|
||||
service: DiscoveredService,
|
||||
options: SyncDiscoveredServicesOptions,
|
||||
): AppItemOptions => ({
|
||||
appId,
|
||||
openInNewTab: true,
|
||||
showTitle: true,
|
||||
descriptionDisplayMode: "hidden",
|
||||
layout: "column",
|
||||
pingEnabled: service.pingUrl
|
||||
? true
|
||||
: !(options.forceDisableStatus ?? false) && (options.enableStatusByDefault ?? true),
|
||||
});
|
||||
|
||||
const findOrCreateCategorySection = (
|
||||
board: BoardWithRelations,
|
||||
groupName: string,
|
||||
sectionsToInsert: InferInsertModel<typeof sections>[],
|
||||
pendingSectionsByBoard: Map<string, InferInsertModel<typeof sections>[]>,
|
||||
sectionYOffsetByBoard: Map<string, number>,
|
||||
): InferInsertModel<typeof sections> & { id: string } => {
|
||||
const pendingForBoard = pendingSectionsByBoard.get(board.id) ?? [];
|
||||
const pendingMatch = pendingForBoard.find((section) => section.kind === "category" && section.name === groupName);
|
||||
if (pendingMatch) {
|
||||
return pendingMatch as InferInsertModel<typeof sections> & { id: string };
|
||||
}
|
||||
|
||||
const existingSection = board.sections.find((section) => section.kind === "category" && section.name === groupName);
|
||||
if (existingSection) {
|
||||
return existingSection;
|
||||
}
|
||||
|
||||
const categorySections = board.sections.filter((section) => section.kind === "category");
|
||||
const pendingCategorySections = pendingForBoard.filter((section) => section.kind === "category");
|
||||
const maxYOffset = Math.max(
|
||||
0,
|
||||
...categorySections.map((section) => section.yOffset ?? 0),
|
||||
...pendingCategorySections.map((section) => section.yOffset ?? 0),
|
||||
sectionYOffsetByBoard.get(board.id) ?? 0,
|
||||
);
|
||||
const yOffset = maxYOffset + 1;
|
||||
sectionYOffsetByBoard.set(board.id, yOffset);
|
||||
|
||||
const newSection: InferInsertModel<typeof sections> = {
|
||||
id: createId(),
|
||||
boardId: board.id,
|
||||
kind: "category",
|
||||
xOffset: 0,
|
||||
yOffset,
|
||||
name: groupName,
|
||||
options: emptySuperJSON,
|
||||
};
|
||||
|
||||
sectionsToInsert.push(newSection);
|
||||
board.sections.push(newSection as BoardWithRelations["sections"][number]);
|
||||
pendingSectionsByBoard.set(board.id, [...pendingForBoard, newSection]);
|
||||
|
||||
return newSection as InferInsertModel<typeof sections> & { id: string };
|
||||
};
|
||||
|
||||
const findAppItemForApp = (
|
||||
boardItems: BoardItem[],
|
||||
pendingItems: InferInsertModel<typeof items>[],
|
||||
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
|
||||
appId: string,
|
||||
sectionId: string,
|
||||
) => {
|
||||
const dbMatch = boardItems.find((item) => itemMatchesAppInSection(item, appId, sectionId));
|
||||
if (dbMatch) {
|
||||
return dbMatch;
|
||||
}
|
||||
|
||||
const pendingMatch = pendingItems.find((item) => {
|
||||
if (item.kind !== "app") {
|
||||
return false;
|
||||
}
|
||||
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options ?? emptySuperJSON);
|
||||
if (itemOptions.appId !== appId) {
|
||||
return false;
|
||||
}
|
||||
return pendingLayouts.some((layout) => layout.itemId === item.id && layout.sectionId === sectionId);
|
||||
});
|
||||
|
||||
return pendingMatch ? { id: pendingMatch.id } : undefined;
|
||||
};
|
||||
|
||||
const itemMatchesAppInSection = (item: BoardItem, appId: string, sectionId: string) => {
|
||||
if (item.kind !== "app") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const itemOptions = SuperJSON.parse<AppItemOptions>(item.options);
|
||||
if (itemOptions.appId !== appId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item.layouts.some((layout) => layout.sectionId === sectionId);
|
||||
};
|
||||
|
||||
const getNextItemYOffset = (
|
||||
board: BoardWithRelations,
|
||||
sectionId: string,
|
||||
layoutId: string,
|
||||
itemYOffsetBySection: Map<string, number>,
|
||||
pendingLayouts: InferInsertModel<typeof itemLayouts>[],
|
||||
itemHeight: number,
|
||||
) => {
|
||||
const sectionKey = `${board.id}/${sectionId}/${layoutId}`;
|
||||
const trackedYOffset = itemYOffsetBySection.get(sectionKey);
|
||||
if (trackedYOffset !== undefined) {
|
||||
const nextYOffset = trackedYOffset + itemHeight;
|
||||
itemYOffsetBySection.set(sectionKey, nextYOffset);
|
||||
return trackedYOffset;
|
||||
}
|
||||
|
||||
const dbLayouts = board.items.flatMap((item) =>
|
||||
item.layouts.filter((layout) => layout.sectionId === sectionId && layout.layoutId === layoutId),
|
||||
);
|
||||
const pendingSectionLayouts = pendingLayouts.filter(
|
||||
(layout) => layout.sectionId === sectionId && layout.layoutId === layoutId,
|
||||
);
|
||||
|
||||
const maxYOffset = [...dbLayouts, ...pendingSectionLayouts].reduce(
|
||||
(max, layout) => Math.max(max, layout.yOffset + layout.height),
|
||||
0,
|
||||
);
|
||||
|
||||
itemYOffsetBySection.set(sectionKey, maxYOffset + itemHeight);
|
||||
return maxYOffset;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export interface DiscoveredService {
|
||||
containerId: string;
|
||||
host: string;
|
||||
group: string;
|
||||
name: string;
|
||||
href: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
pingUrl?: string;
|
||||
externalId: string;
|
||||
boardName?: string;
|
||||
integrationKind?: IntegrationKind;
|
||||
widgetKind?: WidgetKind;
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
|
||||
|
||||
export type ContainerState = (typeof containerStates)[number];
|
||||
export * from "./labels";
|
||||
export * from "./discovery";
|
||||
@@ -1,4 +1,21 @@
|
||||
export const dockerLabels = {
|
||||
// Label to hide a container from Homarrs docker integration
|
||||
hide: "homarr.hide",
|
||||
group: "homarr.group",
|
||||
name: "homarr.name",
|
||||
href: "homarr.href",
|
||||
icon: "homarr.icon",
|
||||
description: "homarr.description",
|
||||
ping: "homarr.ping",
|
||||
id: "homarr.id",
|
||||
board: "homarr.board",
|
||||
integration: "homarr.integration",
|
||||
widget: "homarr.widget",
|
||||
} as const;
|
||||
|
||||
export const homepageLabels = {
|
||||
group: "homepage.group",
|
||||
name: "homepage.name",
|
||||
href: "homepage.href",
|
||||
icon: "homepage.icon",
|
||||
description: "homepage.description",
|
||||
} as const;
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/board-portability": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface InnerProps {
|
||||
board: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ExportBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n() as unknown as (key: string) => string;
|
||||
const { mutateAsync, isPending } = clientApi.board.exportBundle.useMutation();
|
||||
|
||||
const handleExport = async () => {
|
||||
await mutateAsync(
|
||||
{ id: innerProps.board.id },
|
||||
{
|
||||
onSuccess({ content, filename }) {
|
||||
const blob = new Blob([content], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
actions.closeModal();
|
||||
showSuccessNotification({
|
||||
title: t("board.action.export.notification.success.title"),
|
||||
message: t("board.action.export.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("board.action.export.notification.error.title"),
|
||||
message: t("board.action.export.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text size="sm" c="gray.6">
|
||||
{t("board.action.export.label")}: {innerProps.board.name}
|
||||
</Text>
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button loading={isPending} onClick={() => void handleExport()}>
|
||||
{t("board.action.export.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => (t as (key: string) => string)("board.action.export.label"),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AddBoardModal } from "./add-board-modal";
|
||||
export { ImportBoardModal } from "./import-board-modal";
|
||||
export { DuplicateBoardModal } from "./duplicate-board-modal";
|
||||
export { ExportBoardModal } from "./export-board-modal";
|
||||
@@ -5,6 +5,7 @@ export const defaultServerSettingsKeys = [
|
||||
"analytics",
|
||||
"crawlingAndIndexing",
|
||||
"board",
|
||||
"docker",
|
||||
"user",
|
||||
"appearance",
|
||||
"culture",
|
||||
@@ -32,6 +33,14 @@ export const defaultServerSettings = {
|
||||
enableStatusByDefault: true,
|
||||
forceDisableStatus: false,
|
||||
},
|
||||
docker: {
|
||||
targetBoardName: null as string | null,
|
||||
readHomepageLabels: true,
|
||||
pruneRemoved: false,
|
||||
createIntegrations: true,
|
||||
defaultItemWidth: 1,
|
||||
defaultItemHeight: 1,
|
||||
},
|
||||
user: {
|
||||
enableGravatar: true,
|
||||
},
|
||||
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user