feat: add config portability — full export/import, homepage import, docker label discovery

Add a dedicated Import/Export tool page with full configuration export/import
(v2.0 bundle with encrypted secrets), Homepage services.yaml import with env
variable resolution, and manual Docker label discovery (moved from cron job).

Support importing Homarr JSON during onboarding alongside legacy zip import.

New @homarr/board-portability package handles schema, serialization, compat
checks, and import planning. Shared preview UI reused across admin and onboarding.

Fix xterm.js crash by removing deprecated @xterm/addon-canvas (incompatible
with xterm v6). Fix Homepage env variable i18n and React 19 currentTarget bug.

See docs/config-portability.md for full changelog.
This commit is contained in:
Thomas Camlong committed 2026-06-04 10:56:42 +02:00
1 parent 63d8fa8c26
commit fb8721bd4e
100 files changed
+13394 -56

No files matched your search

+1 -1
View File
@@ -21,6 +21,7 @@
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
@@ -63,7 +64,6 @@
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@xterm/addon-canvas": "catalog:",
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
"babel-plugin-react-compiler": "catalog:",
@@ -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,68 @@
"use client";
import { Group, rem, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconJson, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
interface ConfigImportDropZoneProps {
loading: boolean;
updateFile: (file: FileWithPath) => void;
}
export const ConfigImportDropZone = ({ loading, updateFile }: ConfigImportDropZoneProps) => {
const tDropzone = useScopedI18n("init.step.import.configDropzone");
return (
<Dropzone
onDrop={(files) => {
const firstFile = files[0];
if (!firstFile) return;
updateFile(firstFile);
}}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.json]}
loading={loading}
multiple={false}
maxSize={1024 * 1024 * 64}
onReject={(rejections) => {
console.error(
"Rejected files",
rejections.map(
(rejection) =>
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
),
);
}}
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconJson
style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }}
stroke={1.5}
/>
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{tDropzone("title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{tDropzone("description")}
</Text>
</div>
</Group>
</Dropzone>
);
};
@@ -0,0 +1,98 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Card, Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
import { ConfigImportDropZone } from "./config-import-dropzone";
import { FileInfoCard } from "./file-info-card";
export const InitConfigImport = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const [file, setFile] = useState<FileWithPath | null>(null);
const [fileContent, setFileContent] = useState("");
const {
mutate: runPreview,
data: preview,
isPending: previewLoading,
reset: resetPreview,
} = clientApi.import.previewInitialConfigImport.useMutation();
const { mutateAsync, isPending } = clientApi.import.importInitialConfigImport.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
useEffect(() => {
if (!fileContent) {
resetPreview();
return;
}
runPreview({ content: fileContent });
}, [fileContent, runPreview, resetPreview]);
const importAllowed = preview?.compatibility.status === "compatible";
const handleImport = async () => {
if (!file || !importAllowed) {
return;
}
await mutateAsync(
{ content: fileContent },
{
onError() {
showErrorNotification({
title: t("management.page.importExport.importConfig.notification.error.title"),
message: t("management.page.importExport.importConfig.notification.error.message"),
});
},
},
);
};
if (!file) {
return (
<Card w={64 * 12 + 8} maw="90vw">
<ConfigImportDropZone
loading={previewLoading}
updateFile={(selectedFile) => {
void selectedFile.text().then((content) => {
setFileContent(content);
setFile(selectedFile);
});
}}
/>
</Card>
);
}
return (
<Stack mb="sm" w={64 * 12 + 8} maw="90vw">
<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>
</Stack>
);
};
@@ -10,8 +10,16 @@ import { InitialOldmarrImport } from "@homarr/old-import/components";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
import { InitConfigImport } from "./init-config-import";
import { useInitImportMode } from "./use-init-import-mode";
export const InitImport = () => {
const importMode = useInitImportMode();
if (importMode === "config") {
return <InitConfigImport />;
}
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 { Card, Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { IconFileImport, IconJson, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
@@ -22,10 +22,18 @@ export const InitStart = async () => {
</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>
</Card>
);
@@ -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");
};
@@ -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,
@@ -146,6 +147,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
icon: IconDatabaseExport,
href: "/manage/tools/import-export",
hidden: !session?.user.permissions.includes("admin"),
},
],
},
{
@@ -0,0 +1,57 @@
"use client";
import { NumberInput, Switch, TextInput } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["docker"] }) => {
const tDocker = useScopedI18n("management.page.settings.section.docker" as never) as unknown as (
key: string,
) => string;
return (
<CommonSettingsForm settingKey="docker" defaultValues={defaultValues}>
{(form) => (
<>
<TextInput
{...form.getInputProps("targetBoardName")}
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>
</>
);
@@ -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>{importResult.boards} boards imported</List.Item>
<List.Item>{importResult.apps} apps created</List.Item>
<List.Item>{importResult.integrations} integrations created</List.Item>
<List.Item>{importResult.groups} groups created</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,7 +14,7 @@ export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels } = useLogContext();
const terminalRef = useRef<Terminal>(null);
const terminalRef = useRef<Terminal | null>(null);
clientApi.log.subscribe.useSubscription(
{
levels: activeLevels,
@@ -26,7 +25,6 @@ export const TerminalComponent = () => {
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
},
onError(err) {
// This makes sense as logging might cause an infinite loop
alert(err);
},
},
@@ -37,28 +35,30 @@ export const TerminalComponent = () => {
return () => undefined;
}
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
const fitAddon = new FitAddon();
const terminal = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
// This is a hack to make sure the terminal is rendered before we try to fit it
// You can blame @Meierschlumpf for this
setTimeout(() => {
const fitAddon = new FitAddon();
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
const fitTerminal = () => fitAddon.fit();
const fitTimeout = setTimeout(fitTerminal);
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(ref.current);
return () => {
terminalRef.current?.dispose();
canvasAddon.dispose();
clearTimeout(fitTimeout);
resizeObserver.disconnect();
fitAddon.dispose();
terminal.dispose();
terminalRef.current = null;
};
}, []);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
};
@@ -0,0 +1,160 @@
"use client";
import { Alert, Group, List, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { ConfigImportPreview } from "@homarr/board-portability";
import { ConfigSummaryList } from "./config-summary-list";
const compatibilityAlertColor: Record<ConfigImportPreview["compatibility"]["status"], string> = {
compatible: "blue",
unsupportedVersion: "red",
invalidStructure: "red",
};
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>
{(preview.toReuse.apps > 0 || preview.toReuse.integrations > 0) && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillReuse")}
</Text>
<List size="sm">
{preview.toReuse.apps > 0 && (
<List.Item>
{t("management.page.importExport.preview.reuseApps", { count: preview.toReuse.apps })}
</List.Item>
)}
{preview.toReuse.integrations > 0 && (
<List.Item>
{t("management.page.importExport.preview.reuseIntegrations", {
count: preview.toReuse.integrations,
})}
</List.Item>
)}
</List>
</Stack>
)}
{(preview.toSkip.boards > 0 || preview.toSkip.groups > 0 || preview.toSkip.searchEngines > 0) && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillSkip")}
</Text>
<List size="sm">
{preview.toSkip.boards > 0 && (
<List.Item>
{t("management.page.importExport.preview.skipBoards", { count: preview.toSkip.boards })}
</List.Item>
)}
{preview.toSkip.groups > 0 && (
<List.Item>
{t("management.page.importExport.preview.skipGroups", { count: preview.toSkip.groups })}
</List.Item>
)}
{preview.toSkip.searchEngines > 0 && (
<List.Item>
{t("management.page.importExport.preview.skipSearchEngines", {
count: preview.toSkip.searchEngines,
})}
</List.Item>
)}
</List>
</Stack>
)}
{preview.toUpdate.serverSettings > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillUpdate")}
</Text>
<List size="sm">
<List.Item>
{t("management.page.importExport.preview.updateServerSettings", {
count: preview.toUpdate.serverSettings,
})}
</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,48 @@
"use client";
import { List, Text } from "@mantine/core";
import type { ConfigEntityCounts } from "@homarr/board-portability";
const countLabelKeys: Record<keyof ConfigEntityCounts, string> = {
boards: "management.page.importExport.summary.boards",
apps: "management.page.importExport.summary.apps",
integrations: "management.page.importExport.summary.integrations",
secrets: "management.page.importExport.summary.secrets",
widgets: "management.page.importExport.summary.widgets",
sections: "management.page.importExport.summary.sections",
layouts: "management.page.importExport.summary.layouts",
searchEngines: "management.page.importExport.summary.searchEngines",
groups: "management.page.importExport.summary.groups",
serverSettings: "management.page.importExport.summary.serverSettings",
};
const countEntries = (counts: ConfigEntityCounts) =>
(Object.keys(countLabelKeys) as (keyof ConfigEntityCounts)[]).filter((key) => counts[key] > 0);
type ConfigSummaryListProps = {
counts: ConfigEntityCounts;
t: (key: string, values?: Record<string, unknown>) => string;
};
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
const entries = countEntries(counts);
if (entries.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("management.page.importExport.summary.empty")}
</Text>
);
}
return (
<List size="sm" spacing={4}>
{entries.map((key) => (
<List.Item key={key}>
{t(countLabelKeys[key], { count: counts[key] })}
</List.Item>
))}
</List>
);
};
+66
View File
@@ -0,0 +1,66 @@
# Config Portability & Import/Export
## New features
### Full Configuration Export & Import (`/manage/tools/import-export`)
New dedicated tool page under **Manage → Tools → Import / Export** with:
- **Export Full Configuration** — downloads a JSON bundle containing all boards, apps, integrations (with encrypted secrets), server settings, search engines, and groups. The encryption key is embedded so secrets can be restored on another instance. A popover with warnings and a preview summary is shown before download.
- **Import Homarr Configuration** — upload a previously exported JSON file. Shows a compatibility check, a preview of what will be created / reused / skipped / updated, then imports everything transactionally. Server settings that haven't changed are no longer counted as "Will update".
- **Import Homepage services.yaml** — parse a Homepage dashboard `services.yaml`, resolve `{{HOMEPAGE_VAR_*}}` template variables with a form, and create a board with apps and integrations.
- **Docker Label Discovery** — scan running Docker containers for `homarr.*` or `homepage.*` labels and sync discovered services into boards. Moved here from the Docker page. Discovery is now triggered manually via the **Scan** button (the background cron job was removed).
### Onboarding: Import Homarr JSON
The onboarding flow (`/init`) now offers a third option:
- **Import Homarr configuration (.json)** — alongside "Start from scratch" and "Import from Homarr before 1.0"
- Uses the same preview panel (compatibility, create/reuse/skip/update counts) as the admin import tool
- Boards are created with `creatorId: null` (no admin user exists yet at this step)
### Board Portability package (`@homarr/board-portability`)
New package with:
- **Schema** (`homarrConfigBundleSchema`) — v2.0 bundle format with boards, apps, integrations (encrypted secrets), server settings, search engines, groups
- **Export** — `exportFullConfigAsync`, `previewExportFullConfigAsync`, `serializeBoard`, `toJson`
- **Import** — `importFullConfigAsync`, `previewImportFullConfigAsync`, `planFullConfigImport`, `parseBundle`, `matchApps`
- **Homepage** — `parseServicesYaml`, `prepareHomepageImport`, `extractHomepageEnvVariables`, `replaceHomepageEnvVariables`, widget type mapping
- **Compat** — `assessBundleCompatibility`, `parseConfigBundleJson`, format version validation
### Docker Label Discovery (`@homarr/docker/discovery`)
- `listDiscoveredContainersAsync` — scans Docker hosts for labeled containers
- `parseContainerLabels` — parses `homarr.*` and `homepage.*` label sets
- `syncDiscoveredServicesAsync` — creates/updates apps and board items from discovered services
- `docker_app_source` table + migrations (SQLite/MySQL/PostgreSQL) — tracks which apps were created by discovery
### CLI commands (`@homarr/cli`)
- `boards list` — list all boards
- `boards export` — export a board bundle by name
### Export/Import modals (`@homarr/modals-collection`)
- `ExportBoardModal` — single-board export
- `ImportBundleModal` — single-board bundle import
- `ImportHomepageModal` — Homepage services.yaml import (also used from board card menu)
### Example docker-compose (`examples/docker-compose.yml`)
Media stack (Sonarr, Radarr, Prowlarr, qBittorrent, Jellyfin, Overseerr) with `homarr.*` labels for discovery.
## Bug fixes
- **xterm.js crash** — removed `@xterm/addon-canvas` which is incompatible with `@xterm/xterm` v6 (canvas renderer was removed upstream). The logs terminal now uses the built-in DOM renderer. This fixed `TypeError: can't access property "onShowLinkUnderline", this._linkifier2 is undefined` that appeared when navigating between pages.
- **Homepage env variable editing** — fixed `MISSING_MESSAGE: field.envVariables.label` (wrong i18n namespace in import-homepage-card) and `TypeError: can't access property "value", t.currentTarget is null` (React 19 null `currentTarget` in onChange handlers).
## Changes
- **Docker Label Discovery** is no longer a background cron job. The `discoveryEnabled` server setting was removed. Discovery is now triggered manually from the Import/Export page.
- **Docker settings form** no longer shows the "Enable Docker label discovery" toggle.
- **Import/Export sidebar entry** added under Manage → Tools.
- **Board card menu** — removed bundle/homepage import options from the boards create menu (they live in Import/Export now).
- **Server settings preview** compares serialized values and only counts settings that actually differ (re-importing the same export shows 0 updates).
- **Shared UI components** — `ConfigImportPreviewPanel` and `ConfigSummaryList` extracted to `~/components/config-import/` for reuse between the admin tool and onboarding.
+100
View File
@@ -0,0 +1,100 @@
# Homarr Docker label discovery example.
# Prerequisites: Homarr with ENABLE_DOCKER, Docker socket mounted, and in Manage → Settings → Docker
# set a target board name (or label services with homarr.board). Restart Homarr after upgrade so
# migration 0037_add_docker_app_source creates the docker_app_source table.
# Optional: attach this stack to Homarr's network so href/ping hostnames resolve inside Docker.
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: homarr-sonarr
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
volumes:
- sonarr-config:/config
- ./media:/media
ports:
- "8989:8989"
restart: unless-stopped
labels:
# Homarr labels (native)
homarr.name: "Sonarr"
homarr.group: "Media"
homarr.href: "http://sonarr:8989"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
homarr.description: "TV series management"
homarr.ping: "http://sonarr:8989/ping"
homarr.integration: "sonarr"
homarr.widget: "downloads"
# Homepage labels (fallback — used if homarr.* are absent)
homepage.group: "Media"
homepage.name: "Sonarr"
homepage.href: "http://sonarr:8989"
homepage.icon: "sonarr"
homepage.description: "TV series management"
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: homarr-radarr
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
volumes:
- radarr-config:/config
- ./media:/media
ports:
- "7878:7878"
restart: unless-stopped
labels:
homarr.name: "Radarr"
homarr.group: "Media"
homarr.href: "http://radarr:7878"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg"
homarr.description: "Movie management"
homarr.ping: "http://radarr:7878/ping"
homarr.integration: "radarr"
homarr.widget: "downloads"
homepage.group: "Media"
homepage.name: "Radarr"
homepage.href: "http://radarr:7878"
homepage.icon: "radarr"
homepage.description: "Movie management"
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: homarr-qbittorrent
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
- WEBUI_PORT=8080
volumes:
- qbittorrent-config:/config
- ./downloads:/downloads
ports:
- "8080:8080"
- "6881:6881"
- "6881:6881/udp"
restart: unless-stopped
labels:
homarr.name: "qBittorrent"
homarr.group: "Downloads"
homarr.href: "http://qbittorrent:8080"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg"
homarr.description: "Torrent client"
homarr.ping: "http://qbittorrent:8080"
homarr.integration: "qBittorrent"
homarr.widget: "downloads"
homepage.group: "Downloads"
homepage.name: "qBittorrent"
homepage.href: "http://qbittorrent:8080"
homepage.icon: "qbittorrent"
homepage.description: "Torrent client"
volumes:
sonarr-config:
radarr-config:
qbittorrent-config:
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = "24.16.0"
+1
View File
@@ -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",
+1
View File
@@ -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
+117
View File
@@ -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",
});
}
}),
});
/**
+60
View File
@@ -0,0 +1,60 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import {
assessBundleCompatibility,
exportFullConfigAsync,
importFullConfigAsync,
previewExportFullConfigAsync,
previewImportFullConfigAsync,
} from "@homarr/board-portability";
import packageJson from "../../../../package.json";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const configRouter = createTRPCRouter({
previewExport: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await previewExportFullConfigAsync(ctx.db);
}),
previewImport: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
content: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
}),
exportFull: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
return await exportFullConfigAsync(ctx.db, packageJson.version);
}),
importFull: permissionRequiredProcedure
.requiresPermission("admin")
.input(
z.object({
content: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
let parsed: unknown;
try {
parsed = JSON.parse(input.content);
} catch {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid JSON" });
}
const { bundle, compatibility } = assessBundleCompatibility(parsed, packageJson.version);
if (!bundle || compatibility.status !== "compatible") {
throw new TRPCError({
code: "BAD_REQUEST",
message: compatibility.issues.join(" "),
});
}
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
}),
});
@@ -3,7 +3,8 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
import { dockerMiddleware } from "../../middlewares/docker";
@@ -104,6 +105,32 @@ export const dockerRouter = createTRPCRouter({
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
getDiscoveredPreview: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
return await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
}),
syncDiscovery: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.mutation(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
const discovered = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
return await syncDiscoveredServicesAsync(discovered, {
targetBoardName: serverSettings.docker.targetBoardName,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
defaultItemWidth: serverSettings.docker.defaultItemWidth,
defaultItemHeight: serverSettings.docker.defaultItemHeight,
});
}),
});
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
@@ -1,5 +1,11 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import {
assessBundleCompatibility,
importFullConfigAsync,
previewImportFullConfigAsync,
} from "@homarr/board-portability";
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
import {
ensureValidTokenOrThrow,
@@ -7,6 +13,7 @@ import {
importInitialOldmarrInputSchema,
} from "@homarr/old-import/import";
import packageJson from "../../../../../package.json";
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
@@ -40,4 +47,40 @@ export const importRouter = createTRPCRouter({
await importInitialOldmarrAsync(ctx.db, input);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
previewInitialConfigImport: onboardingProcedure
.requiresStep("import")
.input(
z.object({
content: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
}),
importInitialConfigImport: onboardingProcedure
.requiresStep("import")
.input(
z.object({
content: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
let parsed: unknown;
try {
parsed = JSON.parse(input.content);
} catch {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid JSON" });
}
const { bundle, compatibility } = assessBundleCompatibility(parsed, packageJson.version);
if (!bundle || compatibility.status !== "compatible") {
throw new TRPCError({
code: "BAD_REQUEST",
message: compatibility.issues.join(" "),
});
}
await importFullConfigAsync(ctx.db, bundle, null);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
});
@@ -42,6 +42,20 @@ vi.mock("@homarr/docker/env", () => ({
ENABLE_DOCKER: true,
},
}));
vi.mock("@homarr/docker", async (importOriginal) => {
const original = await importOriginal<typeof import("@homarr/docker")>();
return {
...original,
listDiscoveredContainersAsync: async () => [],
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
};
});
vi.mock("@homarr/db/queries", () => ({
getServerSettingsAsync: async () => ({
docker: { targetBoardName: null, readHomepageLabels: true, pruneRemoved: false },
board: { enableStatusByDefault: true, forceDisableStatus: false },
}),
}));
const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) =>
({
@@ -65,6 +79,8 @@ const validInputs: {
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
getDiscoveredPreview: undefined,
syncDiscovery: undefined,
};
describe("All procedures should only be accessible for users with admin permission", () => {
+9
View File
@@ -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";
+21
View File
@@ -0,0 +1,21 @@
export {
CONFIG_BUNDLE_FORMAT_VERSION,
assessBundleCompatibility,
parseConfigBundleJson,
} from "./src/config-bundle-compat";
export { homarrBundleSchema, homarrConfigBundleSchema, type HomarrBundle, type HomarrConfigBundle } from "./src/schema";
export type { ConfigEntityCounts, ConfigExportPreview, ConfigImportPreview } from "./src/types";
export { exportBoardBundleAsync, bundleFilenameForBoard } from "./src/export";
export { exportFullConfigAsync } from "./src/export/export-full-config";
export { previewExportFullConfigAsync } from "./src/export/preview-export-full-config";
export { parseBundleJson, importBoardBundleAsync, type BundleImportReport } from "./src/import";
export { importFullConfigAsync, type ConfigImportReport } from "./src/import/import-full-config";
export { previewImportFullConfigAsync } from "./src/import/plan-full-config-import";
export {
parseServicesYaml,
importHomepageServicesAsync,
homepageWidgetMap,
type HomepageService,
type HomepageImportReport,
type ParseServicesYamlResult,
} from "./src/homepage";
+40
View File
@@ -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,99 @@
import type { HomarrConfigBundle } from "./schema";
import { homarrConfigBundleSchema } from "./schema";
export const CONFIG_BUNDLE_FORMAT_VERSION = "2.0";
export const SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS = [CONFIG_BUNDLE_FORMAT_VERSION] as const;
export type BundleCompatibilityStatus = "compatible" | "unsupportedVersion" | "invalidStructure";
export type BundleCompatibility = {
status: BundleCompatibilityStatus;
bundleVersion: string | null;
bundleHomarrVersion: string | null;
currentHomarrVersion: string;
issues: string[];
};
const versionSupported = (version: string) =>
(SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS as readonly string[]).includes(version);
export const parseConfigBundleJson = (content: string): HomarrConfigBundle => {
const parsed: unknown = JSON.parse(content);
return homarrConfigBundleSchema.parse(parsed);
};
export const assessBundleCompatibility = (
raw: unknown,
currentHomarrVersion: string,
): { bundle: HomarrConfigBundle | null; compatibility: BundleCompatibility } => {
const issues: string[] = [];
if (raw === null || typeof raw !== "object") {
return {
bundle: null,
compatibility: {
status: "invalidStructure",
bundleVersion: null,
bundleHomarrVersion: null,
currentHomarrVersion,
issues: ["File is not a valid JSON object"],
},
};
}
const record = raw as Record<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,
},
};
};
@@ -0,0 +1,214 @@
import SuperJSON from "superjson";
import { env } from "@homarr/common/env";
import type { Database } from "@homarr/db";
import {
apps,
boardGroupPermissions,
boards,
groupPermissions,
groups,
integrationGroupPermissions,
integrations,
integrationSecrets,
searchEngines,
serverSettings,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import { CONFIG_BUNDLE_FORMAT_VERSION } from "../config-bundle-compat";
import type { HomarrBundleApp, HomarrConfigBundle } from "../schema";
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
import { loadBoardGraphAsync } from "./load-board-graph";
const buildBoardSettings = (board: NonNullable<Awaited<ReturnType<typeof loadBoardGraphAsync>>>) => ({
pageTitle: board.pageTitle,
metaTitle: board.metaTitle,
logoImageUrl: board.logoImageUrl,
faviconImageUrl: board.faviconImageUrl,
backgroundImageUrl: board.backgroundImageUrl,
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
customCss: board.customCss,
iconColor: board.iconColor,
itemRadius: board.itemRadius,
disableStatus: board.disableStatus,
isPublic: board.isPublic,
});
export const exportFullConfigAsync = async (
db: Database,
homarrVersion: string,
): Promise<{ content: string; filename: string }> => {
const allBoards = await db.query.boards.findMany();
const allApps = await db.query.apps.findMany();
const allIntegrations = await db.select().from(integrations);
const allSecrets = await db.select().from(integrationSecrets);
const allServerSettings = await db.select().from(serverSettings);
const allSearchEngines = await db.select().from(searchEngines);
const allGroups = await db.select().from(groups);
const allGroupPermissions = await db.select().from(groupPermissions);
const allBoardGroupPermissions = await db.select().from(boardGroupPermissions);
const allIntegrationGroupPermissions = await db.select().from(integrationGroupPermissions);
const secretsByIntegration = new Map<string, { kind: IntegrationSecretKind; value: string }[]>();
for (const secret of allSecrets) {
const list = secretsByIntegration.get(secret.integrationId) ?? [];
list.push({ kind: secret.kind, value: secret.value });
secretsByIntegration.set(secret.integrationId, list);
}
const bundleIntegrations = allIntegrations.map((integration) => ({
ref: integration.id,
kind: integration.kind,
name: integration.name,
url: integration.url,
secrets: secretsByIntegration.get(integration.id) ?? [],
}));
const bundleApps: HomarrBundleApp[] = allApps.map((app) => ({
ref: app.id,
name: app.name,
href: app.href,
iconUrl: app.iconUrl,
description: app.description,
pingUrl: app.pingUrl,
}));
const appIdSet = new Set(allApps.map((a) => a.id));
const appIdToRef = new Map(allApps.map((app) => [app.id, app.id] as const));
const bundleBoards = [];
for (const boardRow of allBoards) {
const boardGraph = await loadBoardGraphAsync(db, boardRow.id);
if (!boardGraph) continue;
const bundleItems = boardGraph.items.map((item) => {
const options = parseStoredValue<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({
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 = new Map<string, string[]>();
for (const gp of allGroupPermissions) {
const list = permissionsByGroup.get(gp.groupId) ?? [];
list.push(gp.permission);
permissionsByGroup.set(gp.groupId, list);
}
const boardPermsByGroup = new Map<string, { boardRef: string; permission: string }[]>();
for (const bp of allBoardGroupPermissions) {
const list = boardPermsByGroup.get(bp.groupId) ?? [];
list.push({ boardRef: bp.boardId, permission: bp.permission });
boardPermsByGroup.set(bp.groupId, list);
}
const integrationPermsByGroup = new Map<string, { integrationRef: string; permission: string }[]>();
for (const ip of allIntegrationGroupPermissions) {
const list = integrationPermsByGroup.get(ip.groupId) ?? [];
list.push({ integrationRef: ip.integrationId, permission: ip.permission });
integrationPermsByGroup.set(ip.groupId, list);
}
const bundleGroups = allGroups.map((group) => ({
ref: group.id,
name: group.name,
position: group.position,
permissions: permissionsByGroup.get(group.id) ?? [],
boardPermissions: boardPermsByGroup.get(group.id) ?? [],
integrationPermissions: integrationPermsByGroup.get(group.id) ?? [],
}));
const bundle: HomarrConfigBundle = {
version: CONFIG_BUNDLE_FORMAT_VERSION,
type: "full-config",
exportedAt: new Date().toISOString(),
homarrVersion,
encryptionKey: env.SECRET_ENCRYPTION_KEY ?? "0".repeat(64),
boards: bundleBoards,
apps: bundleApps,
integrations: bundleIntegrations,
serverSettings: settingsRecord,
searchEngines: bundleSearchEngines,
groups: bundleGroups,
};
const now = new Date().toISOString().slice(0, 10);
return {
content: JSON.stringify(bundle, null, 2),
filename: `homarr-config-${now}.json`,
};
};
@@ -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 {
apps,
boards,
groups,
integrationSecrets,
integrations,
items,
layouts,
searchEngines,
sections,
serverSettings,
} from "@homarr/db/schema";
import type { ConfigExportPreview } from "../types";
export const previewExportFullConfigAsync = async (db: Database): Promise<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 allServerSettings = await db.select().from(serverSettings);
return {
boards: allBoards.length,
apps: allApps.length,
integrations: allIntegrations.length,
secrets: allSecrets.length,
widgets: allItems.length,
sections: allSections.length,
layouts: allLayouts.length,
searchEngines: allSearchEngines.length,
groups: allGroups.length,
serverSettings: allServerSettings.length,
// itemLayouts tracked implicitly via widgets; layouts count covers breakpoints
};
};
@@ -0,0 +1,168 @@
import { inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import type { HomarrBundle, HomarrBundleApp, HomarrBundleIntegration } from "../schema";
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
import type { LoadedBoardGraph } from "./load-board-graph";
const buildBoardSettings = (board: LoadedBoardGraph) => ({
pageTitle: board.pageTitle,
metaTitle: board.metaTitle,
logoImageUrl: board.logoImageUrl,
faviconImageUrl: board.faviconImageUrl,
backgroundImageUrl: board.backgroundImageUrl,
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
customCss: board.customCss,
iconColor: board.iconColor,
itemRadius: board.itemRadius,
disableStatus: board.disableStatus,
isPublic: board.isPublic,
});
const walkValues = (value: unknown, onString: (candidate: string) => void) => {
if (typeof value === "string") {
onString(value);
return;
}
if (Array.isArray(value)) {
value.forEach((entry) => walkValues(entry, onString));
return;
}
if (value !== null && typeof value === "object") {
Object.values(value).forEach((entry) => walkValues(entry, onString));
}
};
export const serializeBoardToBundleAsync = async (
db: Database,
board: LoadedBoardGraph,
homarrVersion: string,
): Promise<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 "./widget-type-map";
export { prepareHomepageImport, resolveHomepageIconUrl, type PreparedHomepageImport } from "./prepare-homepage-import";
export { importHomepageServicesAsync, type HomepageImportReport } from "./import";
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
@@ -0,0 +1,111 @@
import { describe, expect, test } from "vitest";
import { parseServicesYaml } from "./parse-services-yaml";
const sampleYaml = `
- Media:
- Sonarr:
icon: sonarr.png
href: http://sonarr.host/
description: Series management
ping: sonarr.host
widget:
type: sonarr
url: http://sonarr.host
key: apikeyhere
- Radarr:
icon: radarr.png
href: http://radarr.host/
widgets:
- type: radarr
url: http://radarr.host
key: radarrkey
- Downloads:
- qBittorrent:
icon: qbittorrent.png
href: http://qbit.host/
widget:
type: qbittorrent
url: http://qbit.host
username: admin
password: pass123
`;
describe("parseServicesYaml", () => {
test("parses groups, services, singular widget, and plural widgets", () => {
const result = parseServicesYaml(sampleYaml);
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.services).toHaveLength(3);
const sonarr = result.services.find((service) => service.name === "Sonarr");
expect(sonarr).toMatchObject({
group: "Media",
href: "http://sonarr.host/",
ping: "sonarr.host",
});
expect(sonarr?.widgets).toHaveLength(1);
expect(sonarr?.widgets[0]).toMatchObject({ type: "sonarr", key: "apikeyhere" });
const radarr = result.services.find((service) => service.name === "Radarr");
expect(radarr?.widgets).toHaveLength(1);
expect(radarr?.widgets[0]?.type).toBe("radarr");
const qbit = result.services.find((service) => service.name === "qBittorrent");
expect(qbit?.group).toBe("Downloads");
expect(qbit?.widgets[0]).toMatchObject({ type: "qbittorrent", username: "admin" });
});
test("flattens nested groups with separator", () => {
const yaml = `
- Parent:
- Child:
- Service:
href: http://service.local
`;
const result = parseServicesYaml(yaml);
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.services[0]?.group).toBe("Parent / Child");
expect(result.services[0]?.name).toBe("Service");
});
test("parses services without widgets", () => {
const yaml = `
- Tools:
- Homepage:
href: http://homepage.local
`;
const result = parseServicesYaml(yaml);
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.services[0]?.widgets).toEqual([]);
});
test("returns empty services for empty content", () => {
expect(parseServicesYaml(" ")).toEqual({ success: true, services: [] });
});
test("handles malformed yaml gracefully", () => {
const result = parseServicesYaml("invalid: yaml: [");
expect(result.success).toBe(false);
if (result.success) {
return;
}
expect(result.error).toBe("Invalid YAML");
});
test("rejects non-array root documents", () => {
const result = parseServicesYaml("name: not-an-array");
expect(result).toEqual({ success: false, error: "Homepage services.yaml must be a YAML array" });
});
});
@@ -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 } : {}),
...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,299 @@
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import type { InferInsertModel } from "@homarr/db";
import type {
apps,
boards,
integrationItems,
integrationSecrets,
integrations,
itemLayouts,
items,
layouts,
sections,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { emptySuperJSON, integrationDefs } from "@homarr/definitions";
import { stringifyForDb } from "../utils";
import type { HomepageService, HomepageWidget } from "./types";
import { homepageWidgetMap } from "./widget-type-map";
const DASHBOARD_ICON_CDN = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg";
const ENV_VAR_PATTERN = /^\$\{[^}]+\}$/;
const COLUMN_COUNT = 10;
const APP_ITEM_WIDTH = 1;
const APP_ITEM_HEIGHT = 1;
export type PreparedHomepageImport = {
apps: InferInsertModel<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[];
};
const isEnvVarReference = (value: string) => ENV_VAR_PATTERN.test(value.trim());
export const resolveHomepageIconUrl = (icon: string | undefined, serviceName: string): string => {
if (!icon || icon.trim().length === 0) {
const fallback = serviceName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "");
return `${DASHBOARD_ICON_CDN}/${fallback || "generic"}.svg`;
}
if (icon.startsWith("http://") || icon.startsWith("https://")) {
return icon;
}
const baseName = icon.replace(/\.(png|webp|svg|jpg|jpeg)$/i, "");
return `${DASHBOARD_ICON_CDN}/${baseName}.svg`;
};
const resolvePingUrl = (service: HomepageService): string | null => {
const raw = service.ping ?? service.siteMonitor;
if (!raw || raw.trim().length === 0) {
return null;
}
if (raw.startsWith("http://") || raw.startsWith("https://")) {
return raw;
}
return `http://${raw}`;
};
const extractSecretValue = (widget: HomepageWidget, homepageField: string): string | undefined => {
const value = widget[homepageField];
return typeof value === "string" && value.length > 0 ? value : undefined;
};
export const prepareHomepageImport = (
services: HomepageService[],
boardName: string,
creatorId: string,
createIntegrations: boolean,
): PreparedHomepageImport => {
const warnings: string[] = [];
const unmappedWidgetTypesSet = new Set<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.length > 0 ? 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, { x: number; y: number }>();
for (const service of services) {
const groupKey = service.group || "Services";
const sectionId = sectionByGroup.get(groupKey);
if (!sectionId) {
warnings.push(`Skipped "${service.name}" because section "${groupKey}" was not found`);
continue;
}
const appId = createId();
const iconUrl = resolveHomepageIconUrl(service.icon, service.name);
const pingUrl = resolvePingUrl(service);
appRows.push({
id: appId,
name: service.name,
href: service.href ?? null,
iconUrl,
description: service.description ?? null,
pingUrl,
});
const grid = gridState.get(sectionId) ?? { x: 0, y: 0 };
const itemId = createId();
itemRows.push({
id: itemId,
boardId,
kind: "app",
options: stringifyForDb({
appId,
openInNewTab: true,
showTitle: true,
layout: "column",
descriptionDisplayMode: service.description ? "tooltip" : "hidden",
pingEnabled: pingUrl !== null,
}),
advancedOptions: emptySuperJSON,
});
itemLayoutRows.push({
itemId,
sectionId,
layoutId,
width: APP_ITEM_WIDTH,
height: APP_ITEM_HEIGHT,
xOffset: grid.x,
yOffset: grid.y,
});
grid.x += APP_ITEM_WIDTH;
if (grid.x >= COLUMN_COUNT) {
grid.x = 0;
grid.y += APP_ITEM_HEIGHT;
}
gridState.set(sectionId, grid);
if (!createIntegrations) {
for (const widget of service.widgets) {
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
if (mapping === undefined || mapping === null) {
unmappedWidgetTypesSet.add(widget.type);
}
}
continue;
}
for (const widget of service.widgets) {
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
if (mapping === undefined || mapping === null) {
unmappedWidgetTypesSet.add(widget.type);
continue;
}
const integrationId = createId();
const integrationUrl =
(typeof widget.url === "string" && widget.url.length > 0 ? widget.url : service.href) ?? "";
if (integrationUrl.length === 0) {
warnings.push(`Skipped integration for "${service.name}" (${widget.type}): missing URL`);
continue;
}
integrationRows.push({
id: integrationId,
kind: mapping.integrationKind,
name: service.name,
url: integrationUrl,
appId,
});
for (const [homepageField, secretKind] of Object.entries(mapping.secretFieldMap)) {
const rawValue = extractSecretValue(widget, homepageField);
if (!rawValue) {
continue;
}
if (isEnvVarReference(rawValue)) {
warnings.push(
`Unresolved secret for "${service.name}" (${widget.type}): ${homepageField} references an environment variable`,
);
continue;
}
integrationSecretRows.push({
integrationId,
kind: secretKind as IntegrationSecretKind,
value: encryptSecret(rawValue),
});
}
const requiredSecretSets = integrationDefs[mapping.integrationKind].secretKinds;
const providedKinds = new Set(
integrationSecretRows.filter((secret) => secret.integrationId === integrationId).map((secret) => secret.kind),
);
const hasRequiredSecrets = requiredSecretSets.some(
(secretSet) => secretSet.length === 0 || secretSet.every((kind) => providedKinds.has(kind)),
);
if (!hasRequiredSecrets && requiredSecretSets.every((secretSet) => secretSet.length > 0)) {
warnings.push(`Integration "${service.name}" (${mapping.integrationKind}) may be missing required credentials`);
}
const widgetItemId = createId();
const widgetGrid = gridState.get(sectionId) ?? { x: 0, y: 0 };
itemRows.push({
id: widgetItemId,
boardId,
kind: mapping.widgetKind,
options: emptySuperJSON,
advancedOptions: emptySuperJSON,
});
itemLayoutRows.push({
itemId: widgetItemId,
sectionId,
layoutId,
width: 2,
height: 1,
xOffset: widgetGrid.x,
yOffset: widgetGrid.y,
});
widgetGrid.x += 2;
if (widgetGrid.x >= COLUMN_COUNT) {
widgetGrid.x = 0;
widgetGrid.y += 1;
}
gridState.set(sectionId, widgetGrid);
integrationItemRows.push({
itemId: widgetItemId,
integrationId,
});
}
}
return {
apps: appRows,
integrations: integrationRows,
integrationSecrets: integrationSecretRows,
board,
layouts: layoutRows,
sections: sectionRows,
items: itemRows,
itemLayouts: itemLayoutRows,
integrationItems: integrationItemRows,
warnings,
unmappedWidgetTypes: [...unmappedWidgetTypesSet].sort(),
};
};
@@ -0,0 +1,21 @@
export interface HomepageService {
group: string;
name: string;
icon?: string;
href?: string;
description?: string;
ping?: string;
siteMonitor?: string;
widgets: HomepageWidget[];
}
export interface HomepageWidget {
type: string;
url?: string;
key?: string;
[key: string]: unknown;
}
export type ParseServicesYamlResult =
| { success: true; services: HomepageService[] }
| { success: false; error: string };
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { integrationDefs, integrationKinds } from "@homarr/definitions";
import { homepageWidgetMap } from "./widget-type-map";
describe("homepageWidgetMap", () => {
test("returns mapping for known homepage widget types", () => {
const sonarr = homepageWidgetMap.sonarr;
expect(sonarr).toMatchObject({
integrationKind: "sonarr",
widgetKind: "downloads",
secretFieldMap: { key: "apiKey" },
});
});
test("returns null for unknown widget types", () => {
expect(homepageWidgetMap.unknownservice).toBeUndefined();
expect(homepageWidgetMap.bazarr).toBeNull();
expect(homepageWidgetMap.tautulli).toBeNull();
});
test("only maps integration kinds that exist in integrationDefs", () => {
const invalidKinds: string[] = [];
for (const mapping of Object.values(homepageWidgetMap)) {
if (!mapping) {
continue;
}
if (!integrationKinds.includes(mapping.integrationKind)) {
invalidKinds.push(mapping.integrationKind);
}
expect(integrationDefs[mapping.integrationKind]).toBeDefined();
}
expect(invalidKinds).toEqual([]);
});
});
@@ -0,0 +1,55 @@
import type { IntegrationKind, IntegrationSecretKind, WidgetKind } from "@homarr/definitions";
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: {} },
};
@@ -0,0 +1,392 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import { decryptSecretWithKey, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import {
apps,
boardGroupPermissions,
boards,
groupPermissions,
groups,
integrationGroupPermissions,
integrations,
integrationSecrets,
itemLayouts,
items,
layouts,
searchEngines,
sectionLayouts,
sections,
serverSettings,
} from "@homarr/db/schema";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { HomarrBundleBoard, HomarrConfigBundle } from "../schema";
import { replaceAppRefsInValue, stringifyForDb } from "../utils";
export type ConfigImportReport = {
boards: number;
apps: number;
integrations: number;
groups: number;
searchEngines: number;
warnings: string[];
};
const mustGet = <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 reEncryptSecret = (encryptedValue: string, sourceKeyHex: string): `${string}.${string}` => {
const sourceKey = Buffer.from(sourceKeyHex, "hex");
const decrypted = decryptSecretWithKey(encryptedValue as `${string}.${string}`, sourceKey);
return encryptSecret(decrypted);
};
export const importFullConfigAsync = async (
db: Database,
bundle: HomarrConfigBundle,
creatorId: string | null,
): Promise<ConfigImportReport> => {
const warnings: string[] = [];
const sourceKey = bundle.encryptionKey;
const existingApps = await db.query.apps.findMany();
const existingIntegrations = await db.query.integrations.findMany();
const existingBoards = await db.query.boards.findMany();
const existingGroups = await db.select().from(groups);
const existingSearchEngines = await db.select().from(searchEngines);
const existingBoardNames = new Set(existingBoards.map((b) => b.name));
const existingGroupNames = new Set(existingGroups.map((g) => g.name));
const existingSearchEngineShorts = new Set(existingSearchEngines.map((se) => se.short));
const appRefToId = new Map<string, string>();
const appsToInsert: typeof apps.$inferInsert[] = [];
for (const bundleApp of bundle.apps) {
const existing = existingApps.find(
(a) => a.name === bundleApp.name && a.iconUrl === bundleApp.iconUrl && a.href === bundleApp.href,
);
if (existing) {
appRefToId.set(bundleApp.ref, existing.id);
continue;
}
const newId = createId();
appRefToId.set(bundleApp.ref, newId);
appsToInsert.push({
id: newId,
name: bundleApp.name,
iconUrl: bundleApp.iconUrl,
href: bundleApp.href,
description: bundleApp.description ?? null,
pingUrl: bundleApp.pingUrl ?? null,
});
}
const integrationRefToId = new Map<string, string>();
const integrationsToInsert: typeof integrations.$inferInsert[] = [];
const secretsToInsert: typeof integrationSecrets.$inferInsert[] = [];
for (const bundleIntegration of bundle.integrations) {
const existing = existingIntegrations.find(
(i) =>
i.kind === bundleIntegration.kind &&
i.name === bundleIntegration.name &&
i.url === bundleIntegration.url,
);
if (existing) {
integrationRefToId.set(bundleIntegration.ref, existing.id);
continue;
}
const newId = createId();
integrationRefToId.set(bundleIntegration.ref, newId);
integrationsToInsert.push({
id: newId,
kind: bundleIntegration.kind,
name: bundleIntegration.name,
url: bundleIntegration.url,
});
for (const secret of bundleIntegration.secrets) {
try {
const reEncrypted = reEncryptSecret(secret.value, sourceKey);
secretsToInsert.push({
integrationId: newId,
kind: secret.kind as IntegrationSecretKind,
value: reEncrypted,
updatedAt: new Date(),
});
} catch {
warnings.push(`Failed to re-encrypt secret "${secret.kind}" for integration "${bundleIntegration.name}"`);
}
}
}
const groupRefToId = new Map<string, string>();
const groupsToInsert: typeof groups.$inferInsert[] = [];
const groupPermsToInsert: typeof groupPermissions.$inferInsert[] = [];
const boardGroupPermsToInsert: typeof boardGroupPermissions.$inferInsert[] = [];
const integrationGroupPermsToInsert: typeof integrationGroupPermissions.$inferInsert[] = [];
const boardRefToId = new Map<string, string>();
for (const bundleBoard of bundle.boards) {
if (existingBoardNames.has(bundleBoard.name)) {
warnings.push(`Board "${bundleBoard.name}" already exists and was skipped`);
continue;
}
const boardId = createId();
boardRefToId.set(bundleBoard.name, boardId);
}
for (const bundleGroup of bundle.groups) {
if (existingGroupNames.has(bundleGroup.name)) {
const existing = existingGroups.find((g) => g.name === bundleGroup.name);
if (existing) {
groupRefToId.set(bundleGroup.ref, existing.id);
}
warnings.push(`Group "${bundleGroup.name}" already exists and was skipped`);
continue;
}
const newId = createId();
groupRefToId.set(bundleGroup.ref, newId);
groupsToInsert.push({
id: newId,
name: bundleGroup.name,
position: bundleGroup.position,
});
for (const perm of bundleGroup.permissions) {
groupPermsToInsert.push({
groupId: newId,
permission: perm as never,
});
}
}
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
"integrations",
"integrationSecrets",
"boards",
"layouts",
"sections",
"sectionLayouts",
"items",
"itemLayouts",
"integrationItems",
"searchEngines",
"groups",
"groupPermissions",
"boardGroupPermissions",
"integrationGroupPermissions",
]);
insertCollection.apps.push(...appsToInsert);
insertCollection.integrations.push(...integrationsToInsert);
insertCollection.integrationSecrets.push(...secretsToInsert);
insertCollection.groups.push(...groupsToInsert);
insertCollection.groupPermissions.push(...groupPermsToInsert);
let boardsCreated = 0;
for (const bundleBoard of bundle.boards) {
const boardId = boardRefToId.get(bundleBoard.name);
if (!boardId) continue;
boardsCreated++;
const sourceBoard = bundleBoard as HomarrBundleBoard;
const layoutRefToId = new Map(sourceBoard.layouts.map((l) => [l.ref, createId()]));
const sectionRefToId = new Map(sourceBoard.sections.map((s) => [s.ref, createId()]));
const itemRefToId = new Map(sourceBoard.items.map((i) => [i.ref, createId()]));
insertCollection.boards.push({
id: boardId,
name: sourceBoard.name,
creatorId,
isPublic: sourceBoard.settings.isPublic ?? false,
pageTitle: sourceBoard.settings.pageTitle ?? null,
metaTitle: sourceBoard.settings.metaTitle ?? null,
logoImageUrl: sourceBoard.settings.logoImageUrl ?? null,
faviconImageUrl: sourceBoard.settings.faviconImageUrl ?? null,
backgroundImageUrl: sourceBoard.settings.backgroundImageUrl ?? null,
backgroundImageAttachment:
sourceBoard.settings.backgroundImageAttachment ?? backgroundImageAttachments.defaultValue,
backgroundImageRepeat: sourceBoard.settings.backgroundImageRepeat ?? backgroundImageRepeats.defaultValue,
backgroundImageSize: sourceBoard.settings.backgroundImageSize ?? backgroundImageSizes.defaultValue,
primaryColor: sourceBoard.settings.primaryColor,
secondaryColor: sourceBoard.settings.secondaryColor,
opacity: sourceBoard.settings.opacity,
customCss: sourceBoard.settings.customCss ?? null,
iconColor: sourceBoard.settings.iconColor ?? null,
itemRadius: sourceBoard.settings.itemRadius,
disableStatus: sourceBoard.settings.disableStatus ?? false,
});
for (const layout of sourceBoard.layouts) {
insertCollection.layouts.push({
id: mustGet(layoutRefToId, layout.ref),
boardId,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
});
}
for (const section of sourceBoard.sections) {
insertCollection.sections.push({
id: mustGet(sectionRefToId, section.ref),
boardId,
kind: section.kind,
name: section.name ?? null,
xOffset: section.xOffset ?? null,
yOffset: section.yOffset ?? null,
options: section.options ? stringifyForDb(section.options) : undefined,
});
for (const layout of section.layouts ?? []) {
insertCollection.sectionLayouts.push({
sectionId: mustGet(sectionRefToId, section.ref),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef),
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
});
}
}
for (const item of sourceBoard.items) {
const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record<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,
});
}
}
}
}
for (const bundleSe of bundle.searchEngines) {
if (existingSearchEngineShorts.has(bundleSe.short)) {
warnings.push(`Search engine "${bundleSe.name}" (short: ${bundleSe.short}) already exists and was skipped`);
continue;
}
insertCollection.searchEngines.push({
id: createId(),
iconUrl: bundleSe.iconUrl,
name: bundleSe.name,
short: bundleSe.short,
description: bundleSe.description ?? null,
urlTemplate: bundleSe.urlTemplate ?? null,
type: bundleSe.type as never,
integrationId: bundleSe.integrationRef ? (integrationRefToId.get(bundleSe.integrationRef) ?? null) : null,
});
}
for (const bundleGroup of bundle.groups) {
const groupId = groupRefToId.get(bundleGroup.ref);
if (!groupId) continue;
for (const bp of bundleGroup.boardPermissions) {
const resolvedBoardId = boardRefToId.get(bp.boardRef) ?? bp.boardRef;
boardGroupPermsToInsert.push({
groupId,
boardId: resolvedBoardId,
permission: bp.permission as never,
});
}
for (const ip of bundleGroup.integrationPermissions) {
const resolvedIntegrationId = integrationRefToId.get(ip.integrationRef);
if (resolvedIntegrationId) {
integrationGroupPermsToInsert.push({
groupId,
integrationId: resolvedIntegrationId,
permission: ip.permission as never,
});
}
}
}
insertCollection.boardGroupPermissions.push(...boardGroupPermsToInsert);
insertCollection.integrationGroupPermissions.push(...integrationGroupPermsToInsert);
await handleTransactionsAsync(db, {
async handleAsync(innerDb) {
await insertCollection.insertAllAsync(innerDb);
},
handleSync(innerDb) {
insertCollection.insertAll(innerDb);
},
});
for (const [key, value] of Object.entries(bundle.serverSettings)) {
try {
const existing = await db.query.serverSettings.findFirst({
where: (table, { eq }) => eq(table.settingKey, key),
});
if (existing) {
await db
.update(serverSettings)
.set({ value: SuperJSON.stringify(value) })
.where(eq(serverSettings.settingKey, key));
} else {
await db.insert(serverSettings).values({
settingKey: key,
value: SuperJSON.stringify(value),
});
}
} catch {
warnings.push(`Failed to import server setting "${key}"`);
}
}
return {
boards: boardsCreated,
apps: appsToInsert.length,
integrations: integrationsToInsert.length,
groups: groupsToInsert.length,
searchEngines: bundle.searchEngines.length - warnings.filter((w) => w.includes("Search engine")).length,
warnings,
};
};
@@ -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,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,196 @@
import SuperJSON from "superjson";
import type { Database } from "@homarr/db";
import { groups, searchEngines } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions";
import { assessBundleCompatibility } from "../config-bundle-compat";
import type { HomarrConfigBundle } from "../schema";
import type { ConfigEntityCounts, ConfigImportPreview } from "../types";
import { doAppsMatch } from "./match-apps";
const emptyCounts = (): ConfigEntityCounts => ({
boards: 0,
apps: 0,
integrations: 0,
secrets: 0,
widgets: 0,
sections: 0,
layouts: 0,
searchEngines: 0,
groups: 0,
serverSettings: 0,
});
const integrationMatches = (
bundleIntegration: HomarrConfigBundle["integrations"][number],
existing: { kind: IntegrationKind; name: string; url: string },
) =>
bundleIntegration.kind === existing.kind &&
bundleIntegration.name === existing.name &&
bundleIntegration.url === existing.url;
const countBoardEntities = (board: HomarrConfigBundle["boards"][number]) => {
const widgetCount = board.items.length;
const sectionCount = board.sections.length;
const layoutCount = board.layouts.length;
return { widgetCount, sectionCount, layoutCount };
};
export const previewImportFullConfigAsync = async (
db: Database,
content: string,
currentHomarrVersion: string,
): Promise<ConfigImportPreview> => {
const warnings: string[] = [];
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return {
compatibility: {
status: "invalidStructure",
bundleVersion: null,
bundleHomarrVersion: null,
currentHomarrVersion,
issues: ["File is not valid JSON"],
},
meta: { exportedAt: "", homarrVersion: "", bundleVersion: "" },
toCreate: emptyCounts(),
toReuse: { apps: 0, integrations: 0 },
toSkip: { boards: 0, groups: 0, searchEngines: 0 },
toUpdate: { serverSettings: 0 },
warnings: [],
};
}
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
if (!bundle || compatibility.status !== "compatible") {
return {
compatibility,
meta: {
exportedAt: typeof (parsed as Record<string, unknown>).exportedAt === "string" ? ((parsed as Record<string, unknown>).exportedAt as string) : "",
homarrVersion:
typeof (parsed as Record<string, unknown>).homarrVersion === "string"
? ((parsed as Record<string, unknown>).homarrVersion as string)
: "",
bundleVersion:
typeof (parsed as Record<string, unknown>).version === "string"
? ((parsed as Record<string, unknown>).version as string)
: "",
},
toCreate: emptyCounts(),
toReuse: { apps: 0, integrations: 0 },
toSkip: { boards: 0, groups: 0, searchEngines: 0 },
toUpdate: { serverSettings: 0 },
warnings: compatibility.issues,
};
}
const existingApps = await db.query.apps.findMany();
const existingIntegrations = await db.query.integrations.findMany();
const existingBoards = await db.query.boards.findMany();
const existingGroups = await db.select().from(groups);
const existingSearchEngines = await db.select().from(searchEngines);
const existingSettingsByKey = new Map(
(await db.query.serverSettings.findMany()).map((setting) => [setting.settingKey, setting.value] as const),
);
const existingBoardNames = new Set(existingBoards.map((board) => board.name));
const existingGroupNames = new Set(existingGroups.map((group) => group.name));
const existingSearchEngineShorts = new Set(existingSearchEngines.map((engine) => engine.short));
const toCreate = emptyCounts();
let reuseApps = 0;
let reuseIntegrations = 0;
let skipBoards = 0;
let skipGroups = 0;
let skipSearchEngines = 0;
let updateServerSettings = 0;
for (const bundleApp of bundle.apps) {
const existing = existingApps.find((app) =>
doAppsMatch(app, {
name: bundleApp.name,
iconUrl: bundleApp.iconUrl,
description: bundleApp.description ?? null,
href: bundleApp.href,
pingUrl: bundleApp.pingUrl ?? null,
}),
);
if (existing) {
reuseApps += 1;
continue;
}
toCreate.apps += 1;
}
for (const bundleIntegration of bundle.integrations) {
const existing = existingIntegrations.find((integration) => integrationMatches(bundleIntegration, integration));
if (existing) {
reuseIntegrations += 1;
continue;
}
toCreate.integrations += 1;
toCreate.secrets += bundleIntegration.secrets.length;
}
for (const bundleBoard of bundle.boards) {
if (existingBoardNames.has(bundleBoard.name)) {
skipBoards += 1;
warnings.push(`Board "${bundleBoard.name}" already exists and will be skipped`);
continue;
}
toCreate.boards += 1;
const counts = countBoardEntities(bundleBoard);
toCreate.widgets += counts.widgetCount;
toCreate.sections += counts.sectionCount;
toCreate.layouts += counts.layoutCount;
}
for (const bundleGroup of bundle.groups) {
if (existingGroupNames.has(bundleGroup.name)) {
skipGroups += 1;
warnings.push(`Group "${bundleGroup.name}" already exists and will be skipped`);
continue;
}
toCreate.groups += 1;
}
for (const bundleSearchEngine of bundle.searchEngines) {
if (existingSearchEngineShorts.has(bundleSearchEngine.short)) {
skipSearchEngines += 1;
warnings.push(`Search engine "${bundleSearchEngine.name}" already exists and will be skipped`);
continue;
}
toCreate.searchEngines += 1;
}
for (const [settingKey, value] of Object.entries(bundle.serverSettings)) {
const serialized = SuperJSON.stringify(value);
const existingValue = existingSettingsByKey.get(settingKey);
if (existingValue === undefined) {
toCreate.serverSettings += 1;
continue;
}
if (existingValue !== serialized) {
updateServerSettings += 1;
}
}
return {
compatibility,
meta: {
exportedAt: bundle.exportedAt,
homarrVersion: bundle.homarrVersion,
bundleVersion: bundle.version,
},
toCreate,
toReuse: { apps: reuseApps, integrations: reuseIntegrations },
toSkip: { boards: skipBoards, groups: skipGroups, searchEngines: skipSearchEngines },
toUpdate: { serverSettings: updateServerSettings },
warnings,
};
};
@@ -0,0 +1,233 @@
import { createId } from "@homarr/common";
import type { InferInsertModel, InferSelectModel } from "@homarr/db";
import type {
apps,
boards,
integrationItems,
integrations,
itemLayouts,
items,
layouts,
sectionLayouts,
sections,
} from "@homarr/db/schema";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import type { HomarrBundle, HomarrBundleBoard } from "../schema";
import { replaceAppRefsInValue, stringifyForDb } from "../utils";
import { doAppsMatch } from "./match-apps";
const mustGet = <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;
};
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"] = {
id: boardId,
name: boardName,
creatorId,
isPublic: sourceBoard.settings.isPublic ?? false,
pageTitle: sourceBoard.settings.pageTitle ?? null,
metaTitle: sourceBoard.settings.metaTitle ?? null,
logoImageUrl: sourceBoard.settings.logoImageUrl ?? null,
faviconImageUrl: sourceBoard.settings.faviconImageUrl ?? null,
backgroundImageUrl: sourceBoard.settings.backgroundImageUrl ?? null,
backgroundImageAttachment:
sourceBoard.settings.backgroundImageAttachment ?? backgroundImageAttachments.defaultValue,
backgroundImageRepeat: sourceBoard.settings.backgroundImageRepeat ?? backgroundImageRepeats.defaultValue,
backgroundImageSize: sourceBoard.settings.backgroundImageSize ?? backgroundImageSizes.defaultValue,
primaryColor: sourceBoard.settings.primaryColor,
secondaryColor: sourceBoard.settings.secondaryColor,
opacity: sourceBoard.settings.opacity,
customCss: sourceBoard.settings.customCss ?? null,
iconColor: sourceBoard.settings.iconColor ?? null,
itemRadius: sourceBoard.settings.itemRadius,
disableStatus: sourceBoard.settings.disableStatus ?? false,
};
const layouts = sourceBoard.layouts.map((layout) => ({
id: mustGet(layoutRefToId, layout.ref),
boardId,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
}));
const sections = sourceBoard.sections.map((section) => ({
id: mustGet(sectionRefToId, section.ref),
boardId,
kind: section.kind,
name: section.name ?? null,
xOffset: section.xOffset ?? null,
yOffset: section.yOffset ?? null,
options: section.options ? stringifyForDb(section.options) : undefined,
}));
const sectionLayouts = sourceBoard.sections.flatMap((section) =>
(section.layouts ?? []).map((layout) => ({
sectionId: mustGet(sectionRefToId, section.ref),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef),
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
})),
);
const items = sourceBoard.items.map((item) => {
const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record<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,
};
};
+174
View File
@@ -0,0 +1,174 @@
import { z } from "zod/v4";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
integrationKinds,
sectionKinds,
widgetKinds,
} from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import { zodEnumFromArray } from "@homarr/validation/enums";
const bundleVersionSchema = z.literal("1.0");
const bundleBoardSettingsSchema = z.object({
pageTitle: z.string().nullable().optional(),
metaTitle: z.string().nullable().optional(),
logoImageUrl: z.string().nullable().optional(),
faviconImageUrl: z.string().nullable().optional(),
backgroundImageUrl: z.string().nullable().optional(),
backgroundImageAttachment: z.enum(backgroundImageAttachments.values).optional(),
backgroundImageRepeat: z.enum(backgroundImageRepeats.values).optional(),
backgroundImageSize: z.enum(backgroundImageSizes.values).optional(),
primaryColor: z.string(),
secondaryColor: z.string(),
opacity: z.number(),
customCss: z.string().nullable().optional(),
iconColor: z.string().nullable().optional(),
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
disableStatus: z.boolean().optional(),
isPublic: z.boolean().optional(),
});
const bundleLayoutSchema = z.object({
ref: z.string(),
name: z.string(),
columnCount: z.number(),
breakpoint: z.number(),
});
const bundleSectionLayoutSchema = z.object({
layoutRef: z.string(),
parentSectionRef: z.string(),
xOffset: z.number(),
yOffset: z.number(),
width: z.number(),
height: z.number(),
});
const bundleSectionSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(sectionKinds),
name: z.string().nullable().optional(),
yOffset: z.number().nullable().optional(),
xOffset: z.number().nullable().optional(),
options: z.record(z.string(), z.unknown()).optional(),
layouts: z.array(bundleSectionLayoutSchema).optional(),
});
const bundleItemLayoutSchema = z.object({
layoutRef: z.string(),
sectionRef: z.string(),
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
});
const bundleItemSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown()),
advancedOptions: itemAdvancedOptionsSchema,
integrationRefs: z.array(z.string()),
appRef: z.string().optional(),
layouts: z.array(bundleItemLayoutSchema),
});
const bundleBoardSchema = z.object({
name: z.string(),
settings: bundleBoardSettingsSchema,
layouts: z.array(bundleLayoutSchema),
sections: z.array(bundleSectionSchema),
items: z.array(bundleItemSchema),
});
const bundleAppSchema = z.object({
ref: z.string(),
name: z.string(),
href: z.string().nullable(),
iconUrl: z.string(),
description: z.string().nullable().optional(),
pingUrl: z.string().nullable().optional(),
});
const bundleIntegrationSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(integrationKinds),
name: z.string(),
url: z.string(),
secretKinds: z.array(z.string()),
secrets: z.literal("REDACTED"),
});
export const homarrBundleSchema = z.object({
version: bundleVersionSchema,
exportedAt: z.string(),
homarrVersion: z.string(),
boards: z.array(bundleBoardSchema).min(1),
apps: z.array(bundleAppSchema),
integrations: z.array(bundleIntegrationSchema),
});
export type HomarrBundle = z.infer<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 configBundleGroupSchema = z.object({
ref: z.string(),
name: z.string(),
position: z.number(),
permissions: z.array(z.string()),
boardPermissions: z.array(
z.object({ boardRef: z.string(), permission: z.string() }),
),
integrationPermissions: z.array(
z.object({ integrationRef: z.string(), permission: z.string() }),
),
});
export const homarrConfigBundleSchema = z.object({
version: z.literal("2.0"),
type: z.literal("full-config"),
exportedAt: z.string(),
homarrVersion: z.string(),
encryptionKey: z.string(),
boards: z.array(bundleBoardSchema),
apps: z.array(bundleAppSchema),
integrations: z.array(configBundleIntegrationSchema),
serverSettings: z.record(z.string(), z.unknown()),
searchEngines: z.array(configBundleSearchEngineSchema),
groups: z.array(configBundleGroupSchema),
});
export type HomarrConfigBundle = z.infer<typeof homarrConfigBundleSchema>;
export type ConfigBundleIntegration = z.infer<typeof configBundleIntegrationSchema>;
export type ConfigBundleGroup = z.infer<typeof configBundleGroupSchema>;
export type ConfigBundleSearchEngine = z.infer<typeof configBundleSearchEngineSchema>;
+39
View File
@@ -0,0 +1,39 @@
import type { BundleCompatibility } from "./config-bundle-compat";
export type ConfigEntityCounts = {
boards: number;
apps: number;
integrations: number;
secrets: number;
widgets: number;
sections: number;
layouts: number;
searchEngines: number;
groups: number;
serverSettings: number;
};
export type ConfigExportPreview = ConfigEntityCounts;
export type ConfigImportPreview = {
compatibility: BundleCompatibility;
meta: {
exportedAt: string;
homarrVersion: string;
bundleVersion: string;
};
toCreate: ConfigEntityCounts;
toReuse: {
apps: number;
integrations: number;
};
toSkip: {
boards: number;
groups: number;
searchEngines: number;
};
toUpdate: {
serverSettings: number;
};
warnings: string[];
};
+52
View File
@@ -0,0 +1,52 @@
import superjson from "superjson";
import { emptySuperJSON } from "@homarr/definitions";
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 replaceAppIdsInValue = (value: unknown, appIdToRef: Map<string, string>): unknown => {
if (typeof value === "string") {
const ref = appIdToRef.get(value);
return ref ?? value;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceAppIdsInValue(entry, appIdToRef));
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, replaceAppIdsInValue(entry, appIdToRef)]),
);
}
return value;
};
export const replaceAppRefsInValue = (value: unknown, refToAppId: Map<string, string>): unknown => {
if (typeof value === "string") {
const appId = refToAppId.get(value);
return appId ?? value;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceAppRefsInValue(entry, refToAppId));
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, replaceAppRefsInValue(entry, refToAppId)]),
);
}
return value;
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+1
View File
@@ -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}`);
},
});
+21
View File
@@ -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"}`);
}
},
});
+10
View File
@@ -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],
});
+2 -1
View File
@@ -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",
+1
View File
@@ -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
@@ -274,6 +274,13 @@
"when": 1775400000000,
"tag": "0038_add_user_ddg_bangs",
"breakpoints": true
},
{
"idx": 39,
"version": "5",
"when": 1780490691902,
"tag": "0039_add_docker_app_source",
"breakpoints": true
}
]
}
}
@@ -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
@@ -36,6 +36,13 @@
"when": 1775400000000,
"tag": "0004_add_user_ddg_bangs",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1780490693344,
"tag": "0005_add_docker_app_source",
"breakpoints": true
}
]
}
}
@@ -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
@@ -260,6 +260,13 @@
"when": 1775400000000,
"tag": "0036_add_user_ddg_bangs",
"breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1780490686287,
"tag": "0037_add_docker_app_source",
"breakpoints": true
}
]
}
}
+1
View File
@@ -49,6 +49,7 @@ export const {
sectionLayouts,
trustedCertificateHostnames,
cronJobConfigurations,
dockerAppSources,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
+46
View File
@@ -502,6 +502,33 @@ export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = mysqlTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -766,3 +793,22 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
references: [boards.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
+46
View File
@@ -501,6 +501,33 @@ export const cronJobConfigurations = pgTable("cron_job_configuration", {
isEnabled: boolean().default(true).notNull(),
});
export const dockerAppSources = pgTable(
"docker_app_source",
{
host: varchar({ length: 512 }).notNull(),
containerId: varchar({ length: 128 }).notNull(),
externalId: varchar({ length: 512 }).notNull(),
appId: varchar({ length: 64 })
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: varchar({ length: 64 }).references(() => items.id, { onDelete: "set null" }),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "set null" }),
updatedAt: timestamp()
.notNull()
.$onUpdateFn(() => new Date())
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -765,3 +792,22 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
references: [boards.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
+46
View File
@@ -487,6 +487,33 @@ export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
isEnabled: int({ mode: "boolean" }).default(true).notNull(),
});
export const dockerAppSources = sqliteTable(
"docker_app_source",
{
host: text().notNull(),
containerId: text().notNull(),
externalId: text().notNull(),
appId: text()
.notNull()
.references(() => apps.id, { onDelete: "cascade" }),
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
itemId: text().references(() => items.id, { onDelete: "set null" }),
integrationId: text().references(() => integrations.id, { onDelete: "set null" }),
updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.host, table.containerId],
}),
externalIdIdx: index("docker_app_source__external_id_idx").on(table.host, table.externalId),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -751,3 +778,22 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
references: [boards.id],
}),
}));
export const dockerAppSourceRelations = relations(dockerAppSources, ({ one }) => ({
app: one(apps, {
fields: [dockerAppSources.appId],
references: [apps.id],
}),
board: one(boards, {
fields: [dockerAppSources.boardId],
references: [boards.id],
}),
item: one(items, {
fields: [dockerAppSources.itemId],
references: [items.id],
}),
integration: one(integrations, {
fields: [dockerAppSources.integrationId],
references: [integrations.id],
}),
}));
+6 -2
View File
@@ -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,137 @@
import type { ContainerInfo } from "dockerode";
import { describe, expect, test } from "vitest";
import { dockerLabels, homepageLabels } from "../../labels";
import { parseContainerLabels } from "../parse-container-labels";
const createContainer = (labels: Record<string, string>): Pick<ContainerInfo, "Id" | "Labels"> => ({
Id: "container-abc123",
Labels: labels,
});
describe("parseContainerLabels", () => {
test("parses full homarr labels into DiscoveredService", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Jellyfin",
[dockerLabels.href]: "https://jellyfin.local",
[dockerLabels.icon]: "https://cdn.example/icon.png",
[dockerLabels.description]: "Media server",
[dockerLabels.ping]: "https://jellyfin.local/health",
[dockerLabels.id]: "jellyfin-1",
[dockerLabels.board]: "homelab",
[dockerLabels.integration]: "jellyfin",
[dockerLabels.widget]: "mediaServer",
}),
"socket",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "socket",
group: "Media",
name: "Jellyfin",
href: "https://jellyfin.local",
icon: "https://cdn.example/icon.png",
description: "Media server",
pingUrl: "https://jellyfin.local/health",
externalId: "jellyfin-1",
boardName: "homelab",
integrationKind: "jellyfin",
widgetKind: "mediaServer",
});
});
test("falls back to homepage labels when homarr.name is absent", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
[homepageLabels.icon]: "https://cdn.example/sonarr.png",
[homepageLabels.description]: "TV shows",
}),
"192.168.1.10:2375",
);
expect(result).toEqual({
containerId: "container-abc123",
host: "192.168.1.10:2375",
group: "Downloads",
name: "Sonarr",
href: "https://sonarr.local",
icon: "https://cdn.example/sonarr.png",
description: "TV shows",
externalId: "container-abc123",
});
});
test("skips containers with homarr.hide", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.hide]: "true",
[dockerLabels.group]: "Media",
[dockerLabels.name]: "Hidden",
[dockerLabels.href]: "https://hidden.local",
}),
"socket",
);
expect(result).toBeNull();
});
test("skips containers missing required labels", () => {
expect(
parseContainerLabels(
createContainer({
[dockerLabels.name]: "Incomplete",
[dockerLabels.href]: "https://incomplete.local",
}),
"socket",
),
).toBeNull();
expect(
parseContainerLabels(
createContainer({
[homepageLabels.name]: "No group",
[homepageLabels.href]: "https://nogroup.local",
}),
"socket",
),
).toBeNull();
});
test("prefers homarr labels over homepage when both are present", () => {
const result = parseContainerLabels(
createContainer({
[dockerLabels.group]: "Homarr Group",
[dockerLabels.name]: "Homarr Name",
[dockerLabels.href]: "https://homarr.local",
[homepageLabels.group]: "Homepage Group",
[homepageLabels.name]: "Homepage Name",
[homepageLabels.href]: "https://homepage.local",
}),
"socket",
);
expect(result?.group).toBe("Homarr Group");
expect(result?.name).toBe("Homarr Name");
expect(result?.href).toBe("https://homarr.local");
});
test("does not use homepage fallback when readHomepageLabels is false", () => {
const result = parseContainerLabels(
createContainer({
[homepageLabels.group]: "Downloads",
[homepageLabels.name]: "Sonarr",
[homepageLabels.href]: "https://sonarr.local",
}),
"socket",
{ readHomepageLabels: false },
);
expect(result).toBeNull();
});
});
+8
View File
@@ -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;
};
+16
View File
@@ -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;
}
+1
View File
@@ -9,3 +9,4 @@ export const containerStates = ["created", "running", "paused", "restarting", "e
export type ContainerState = (typeof containerStates)[number];
export * from "./labels";
export * from "./discovery";
+18 -1
View File
@@ -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;
+1
View File
@@ -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"),
});
@@ -0,0 +1,139 @@
import { useState } from "react";
import { Button, FileInput, Group, Stack, TextInput } from "@mantine/core";
import { IconFileUpload } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { homarrBundleSchema } from "@homarr/board-portability/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { boardNameSchema } from "@homarr/validation/board";
import { useBoardNameStatus } from "./add-board-modal";
export const ImportBundleModal = createModal(({ actions }) => {
const t = useScopedI18n("board.action.importBundle" as never) as unknown as (key: string) => string;
const tCommon = useScopedI18n("common");
const [fileValid, setFileValid] = useState(true);
const form = useZodForm(
z.object({
file: z.file(),
name: boardNameSchema,
}),
{
mode: "controlled",
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
name: "",
},
onValuesChange(values, previous) {
void (async () => {
if (values.file === previous.file) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!values.file) {
return;
}
const content = await values.file.text();
const result = homarrBundleSchema.safeParse(JSON.parse(content));
if (!result.success) {
setFileValid(false);
return;
}
setFileValid(true);
const boardName = result.data.boards[0]?.name.replaceAll(" ", "-") ?? "";
form.setFieldValue("name", boardName);
})();
},
},
);
const { mutateAsync, isPending } = clientApi.board.importBundle.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/boards");
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);
return (
<form
onSubmit={form.onSubmit((values) => {
if (!fileValid || !boardNameStatus.canSubmit) {
return;
}
void (async () => {
const content = await values.file.text();
await mutateAsync(
{ content, name: values.name },
{
onSuccess() {
actions.closeModal();
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
})();
})}
>
<Stack>
<FileInput
rightSection={<IconFileUpload />}
withAsterisk
accept="application/json"
{...form.getInputProps("file")}
error={
(form.getInputProps("file").error as string | undefined) ??
(!fileValid && form.isDirty("file") ? t("form.file.invalidError") : undefined)
}
type="button"
label={t("form.file.label")}
/>
<TextInput
withAsterisk
label={t("form.name.label")}
description={
boardNameStatus.description ? (
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
) : null
}
{...form.getInputProps("name")}
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.import")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => (t as (key: string) => string)("board.action.importBundle.label"),
size: "lg",
});
@@ -0,0 +1,195 @@
import { useState } from "react";
import { Button, Code, FileInput, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import { IconFileUpload } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import {
extractHomepageEnvVariables,
parseServicesYaml,
replaceHomepageEnvVariables,
} from "@homarr/board-portability/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { boardNameSchema } from "@homarr/validation/board";
import { useBoardNameStatus } from "./add-board-modal";
export const ImportHomepageModal = createModal(({ actions }) => {
const t = useScopedI18n("board.action.importHomepage" as never) as unknown as (key: string) => string;
const tCommon = useScopedI18n("common");
const [fileValid, setFileValid] = useState(true);
const [rawContent, setRawContent] = useState("");
const [envVars, setEnvVars] = useState<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) => [v, ""])));
const result = parseServicesYaml(content);
if (!result.success) {
setFileValid(false);
return;
}
setFileValid(true);
const suggestedName =
result.services[0]?.group.replaceAll(/[^A-Za-z0-9-_]/g, "-").replaceAll(/-+/g, "-") ?? "homepage";
if (form.values.boardName.trim().length === 0) {
form.setFieldValue("boardName", suggestedName.slice(0, 255));
}
})();
},
},
);
const { mutateAsync, isPending } = clientApi.board.importHomepageServices.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/boards");
},
});
const boardNameStatus = useBoardNameStatus(form.values.boardName);
const allEnvVarsFilled = envVars.length === 0 || envVars.every((v) => (envValues[v] ?? "").trim().length > 0);
return (
<form
onSubmit={form.onSubmit((values) => {
if (!fileValid || !boardNameStatus.canSubmit || !allEnvVarsFilled) {
return;
}
void (async () => {
const resolvedContent =
envVars.length > 0 ? replaceHomepageEnvVariables(rawContent, envValues) : rawContent;
await mutateAsync(
{
content: resolvedContent,
boardName: values.boardName,
createIntegrations: values.createIntegrations,
},
{
onSuccess() {
actions.closeModal();
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
})();
})}
>
<Stack>
<FileInput
rightSection={<IconFileUpload />}
withAsterisk
accept=".yaml,.yml"
{...form.getInputProps("file")}
error={
(form.getInputProps("file").error as string | undefined) ??
(!fileValid && form.isDirty("file") ? t("field.file.invalidError") : undefined)
}
type="button"
label={t("field.file.label")}
/>
{envVars.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("field.envVariables.label")}
</Text>
<Text size="xs" c="dimmed">
{t("field.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("field.boardName.label")}
description={
boardNameStatus.description ? (
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
) : null
}
{...form.getInputProps("boardName")}
/>
<Switch
label={t("field.createIntegrations.label")}
description={t("field.createIntegrations.description")}
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending} disabled={!allEnvVarsFilled}>
{tCommon("action.import")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => (t as (key: string) => string)("board.action.importHomepage.modal.title"),
size: "lg",
});
@@ -1,3 +1,6 @@
export { AddBoardModal } from "./add-board-modal";
export { ImportBoardModal } from "./import-board-modal";
export { ImportBundleModal } from "./import-bundle-modal";
export { ImportHomepageModal } from "./import-homepage-modal";
export { DuplicateBoardModal } from "./duplicate-board-modal";
export { ExportBoardModal } from "./export-board-modal";
+9
View File
@@ -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,
},
+233 -2
View File
@@ -7,12 +7,20 @@
"description": "To get started, please select how you want to set up your Homarr instance.",
"action": {
"scratch": "Start from scratch",
"importOldmarr": "Import from Homarr before 1.0"
"importOldmarr": "Import from Homarr before 1.0",
"importConfig": "Import Homarr configuration (.json)"
}
},
"import": {
"title": "Import data",
"subtitle": "You can import data from an existing Homarr instance.",
"configDropzone": {
"title": "Drag the configuration file here or click to browse",
"description": "Upload a Homarr full configuration export (.json) to restore boards, apps, integrations, and settings"
},
"configImport": {
"action": "Confirm import and continue"
},
"dropzone": {
"title": "Drag the zip file here or click to browse",
"description": "The uploaded zip will be processed and you'll be able to select what you want to import"
@@ -3008,6 +3016,74 @@
}
}
},
"export": {
"label": "Export board",
"notification": {
"success": {
"title": "Board exported",
"message": "The board bundle was downloaded successfully"
},
"error": {
"title": "Unable to export board",
"message": "The board could not be exported"
}
}
},
"importHomepage": {
"label": "Import from Homepage",
"notification": {
"success": {
"title": "Homepage import complete",
"message": "Board created from Homepage services"
},
"error": {
"title": "Import failed",
"message": "Could not import Homepage services"
}
},
"modal": {
"title": "Import Homepage services.yaml"
},
"field": {
"file": {
"label": "Select services.yaml file",
"invalidError": "Invalid Homepage services.yaml file"
},
"boardName": {
"label": "Board name"
},
"createIntegrations": {
"label": "Create integrations",
"description": "Automatically create integrations for recognized services"
},
"envVariables": {
"label": "Environment variables",
"description": "Your services.yaml contains template variables. Fill in the values they should resolve to."
}
}
},
"importBundle": {
"label": "Import board bundle",
"notification": {
"success": {
"title": "Import successful",
"message": "The board bundle was imported successfully"
},
"error": {
"title": "Import failed",
"message": "The board bundle could not be imported"
}
},
"form": {
"file": {
"label": "Select JSON bundle",
"invalidError": "Invalid board bundle file"
},
"name": {
"label": "Board name"
}
}
},
"edit": {
"notification": {
"success": {
@@ -3378,7 +3454,8 @@
"logs": "Logs",
"api": "API",
"certificates": "Certificates",
"tasks": "Tasks"
"tasks": "Tasks",
"importExport": "Import / Export"
}
},
"settings": "Settings",
@@ -3446,6 +3523,9 @@
"duplicate": {
"label": "Duplicate board"
},
"export": {
"label": "Export board"
},
"delete": {
"label": "Delete permanently",
"confirm": {
@@ -3601,6 +3681,99 @@
}
}
},
"importExport": {
"title": "Import / Export",
"summary": {
"title": "Contents",
"empty": "Nothing to include",
"boards": "{count} boards",
"apps": "{count} apps",
"integrations": "{count} integrations",
"secrets": "{count} integration secrets",
"widgets": "{count} widgets",
"sections": "{count} sections",
"layouts": "{count} layouts",
"searchEngines": "{count} search engines",
"groups": "{count} groups",
"serverSettings": "{count} server settings"
},
"preview": {
"exportLoading": "Loading export summary…",
"importLoading": "Analyzing configuration file…",
"importIncompatible": "This file cannot be imported on this Homarr version",
"importMeta": "Exported {exportedAt} from Homarr {homarrVersion} (format {bundleVersion})",
"importWillCreate": "Will create",
"importWillReuse": "Will match existing",
"importWillSkip": "Will skip (already exists)",
"importWillUpdate": "Will update",
"reuseApps": "{count} apps",
"reuseIntegrations": "{count} integrations",
"skipBoards": "{count} boards",
"skipGroups": "{count} groups",
"skipSearchEngines": "{count} search engines",
"updateServerSettings": "{count} server settings",
"migrationNote": "Import requires this instance to have run all database migrations. Bundle format {bundleVersion} is validated against the current schema before import."
},
"export": {
"title": "Export Full Configuration",
"description": "Export all boards, apps, integrations (with encrypted secrets), server settings, search engines, and groups as a portable JSON file. The encryption key is embedded so secrets can be restored on import.",
"action": "Export Configuration",
"warning": {
"title": "This export contains secrets",
"message": "The downloaded JSON includes your full Homarr configuration. Integration credentials stay encrypted, but the file also embeds your instance encryption key so another Homarr instance can decrypt them on import.",
"storage": "Store the file only in a safe place. Anyone with this file can read and reuse your integration secrets.",
"sharing": "Do not share it in chat, email, cloud folders without encryption, or public issue trackers."
},
"notification": {
"success": {
"title": "Export successful",
"message": "Your configuration has been exported"
},
"error": {
"title": "Export failed",
"message": "Failed to export configuration"
}
}
},
"importConfig": {
"title": "Import Homarr Configuration",
"description": "Import a previously exported Homarr configuration file. Existing boards, apps, and integrations with the same identity will be reused. Secrets will be re-encrypted with this instance's key.",
"fileLabel": "Configuration file (.json)",
"invalidFile": "Invalid Homarr configuration file",
"notification": {
"success": {
"title": "Import successful",
"message": "Configuration has been imported"
},
"error": {
"title": "Import failed",
"message": "Failed to import configuration"
}
}
},
"importHomepage": {
"title": "Import Homepage services.yaml",
"description": "Import a Homepage dashboard services.yaml file to create a new board with apps and integrations.",
"fileLabel": "Homepage services.yaml file",
"invalidFile": "Invalid services.yaml file",
"boardNameLabel": "Board name",
"createIntegrations": "Create integrations from Homepage widgets",
"envVariables": {
"label": "Environment variables",
"description": "Your services.yaml contains template variables. Fill in the values they should resolve to."
},
"notification": {
"success": {
"title": "Import successful",
"message": "Homepage services have been imported as a new board"
},
"error": {
"title": "Import failed",
"message": "Failed to import Homepage services"
}
}
}
},
"settings": {
"title": "Settings",
"notification": {
@@ -3700,6 +3873,33 @@
"defaultLocale": {
"label": "Default language"
}
},
"docker": {
"title": "Docker Discovery",
"targetBoardName": {
"label": "Default target board",
"description": "Board name to place discovered apps on when no board label is set"
},
"readHomepageLabels": {
"label": "Read Homepage labels",
"description": "Fall back to homepage.* Docker labels when homarr.* labels are not present"
},
"createIntegrations": {
"label": "Auto-create integrations",
"description": "Automatically create integrations when a container has homarr.integration or homepage widget labels"
},
"pruneRemoved": {
"label": "Remove stale apps",
"description": "Delete apps from boards when their source container is no longer running"
},
"defaultItemWidth": {
"label": "Default item width",
"description": "Grid column width for discovered app tiles"
},
"defaultItemHeight": {
"label": "Default item height",
"description": "Grid row height for discovered app tiles"
}
}
}
},
@@ -3944,6 +4144,34 @@
},
"docker": {
"title": "Containers",
"discovery": {
"title": "Label Discovery",
"sync": {
"label": "Sync now",
"notification": {
"success": {
"title": "Discovery sync complete",
"message": "Created {created}, updated {updated} services"
},
"error": {
"title": "Discovery sync failed",
"message": "Could not sync discovered services. Check server logs for details."
}
}
},
"preview": {
"label": "Scan",
"title": "Discovered services",
"idle": "Press Scan to find running containers with Homarr or Homepage labels",
"empty": "No containers with homarr or homepage labels were found"
},
"field": {
"name": "Name",
"group": "Group",
"href": "URL",
"host": "Host"
}
},
"table": {
"updated": "Updated {when}",
"search": "Seach {count} containers",
@@ -4435,6 +4663,9 @@
"hostnames": {
"label": "Hostnames"
}
},
"import-export": {
"label": "Import / Export"
}
},
"settings": {
+9
View File
@@ -94,3 +94,12 @@ export const boardCreateSchema = z.object({
});
export const boardSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions));
export const boardExportBundleSchema = z.object({
id: z.string(),
});
export const boardImportBundleSchema = z.object({
content: z.string().min(1),
name: boardNameSchema,
});
+65 -21
View File
@@ -273,9 +273,6 @@ catalogs:
'@vitest/ui':
specifier: ^4.1.7
version: 4.1.7
'@xterm/addon-canvas':
specifier: ^0.7.0
version: 0.7.0
'@xterm/addon-fit':
specifier: 0.11.0
version: 0.11.0
@@ -655,6 +652,9 @@ importers:
'@homarr/auth':
specifier: workspace:^0.1.0
version: link:../../packages/auth
'@homarr/board-portability':
specifier: workspace:^0.1.0
version: link:../../packages/board-portability
'@homarr/boards':
specifier: workspace:^0.1.0
version: link:../../packages/boards
@@ -781,9 +781,6 @@ importers:
'@trpc/server':
specifier: 'catalog:'
version: 11.17.0(typescript@5.9.3)
'@xterm/addon-canvas':
specifier: 'catalog:'
version: 0.7.0(@xterm/xterm@6.0.0)
'@xterm/addon-fit':
specifier: 'catalog:'
version: 0.11.0
@@ -1067,6 +1064,9 @@ importers:
'@homarr/auth':
specifier: workspace:^0.1.0
version: link:../auth
'@homarr/board-portability':
specifier: workspace:^0.1.0
version: link:../board-portability
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
@@ -1226,6 +1226,43 @@ importers:
specifier: 'catalog:'
version: 5.9.3
packages/board-portability:
dependencies:
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
'@homarr/core':
specifier: workspace:^0.1.0
version: link:../core
'@homarr/db':
specifier: workspace:^0.1.0
version: link:../db
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
superjson:
specifier: 'catalog:'
version: 2.2.6
yaml:
specifier: 'catalog:'
version: 2.9.0
zod:
specifier: 'catalog:'
version: 4.4.3
devDependencies:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
typescript:
specifier: 'catalog:'
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.1.7(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.12.4)(typescript@5.9.3))(vite@7.1.12(@types/node@24.12.4)(jiti@1.21.7)(sass@1.100.0)(sugarss@5.0.0(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
packages/boards:
dependencies:
'@homarr/api':
@@ -1253,6 +1290,9 @@ importers:
'@homarr/auth':
specifier: workspace:^0.1.0
version: link:../auth
'@homarr/board-portability':
specifier: workspace:^0.1.0
version: link:../board-portability
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
@@ -1404,6 +1444,9 @@ importers:
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/docker':
specifier: workspace:^0.1.0
version: link:../docker
'@homarr/icons':
specifier: workspace:^0.1.0
version: link:../icons
@@ -1570,9 +1613,18 @@ importers:
'@homarr/core':
specifier: workspace:^0.1.0
version: link:../core
'@homarr/db':
specifier: workspace:^0.1.0
version: link:../db
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
dockerode:
specifier: 'catalog:'
version: 5.0.0
superjson:
specifier: 'catalog:'
version: 2.2.6
devDependencies:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
@@ -1583,6 +1635,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.1.7(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.12.4)(typescript@5.9.3))(vite@7.1.12(@types/node@24.12.4)(jiti@1.21.7)(sass@1.100.0)(sugarss@5.0.0(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
packages/form:
dependencies:
@@ -1817,6 +1872,9 @@ importers:
'@homarr/api':
specifier: workspace:^0.1.0
version: link:../api
'@homarr/board-portability':
specifier: workspace:^0.1.0
version: link:../board-portability
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
@@ -5569,11 +5627,6 @@ packages:
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xterm/addon-canvas@0.7.0':
resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
@@ -6526,9 +6579,6 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.0.0:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
@@ -12956,10 +13006,6 @@ snapshots:
'@xmldom/xmldom@0.8.10': {}
'@xterm/addon-canvas@0.7.0(@xterm/xterm@6.0.0)':
dependencies:
'@xterm/xterm': 6.0.0
'@xterm/addon-fit@0.11.0': {}
'@xterm/xterm@6.0.0': {}
@@ -13785,8 +13831,6 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@2.0.0: {}
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
@@ -17204,7 +17248,7 @@ snapshots:
'@vitest/snapshot': 4.1.7
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
es-module-lexer: 2.0.0
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
-1
View File
@@ -93,7 +93,6 @@ catalog:
"@vitejs/plugin-react": ^5.2.0
"@vitest/coverage-v8": ^4.1.7
"@vitest/ui": ^4.1.7
"@xterm/addon-canvas": ^0.7.0
"@xterm/addon-fit": 0.11.0
"@xterm/xterm": ^6.0.0
adm-zip: 0.5.17