Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6a598b774 | ||
|
|
1f7b7f0516 | ||
|
|
572d5fb5ec | ||
|
|
91a1858743 | ||
|
|
f00d3627c9 | ||
|
|
7760e01ccb | ||
|
|
efd37dca93 | ||
|
|
796c679140 | ||
|
|
76fd4c5fb1 | ||
|
|
68f24e34d5 | ||
|
|
22d7ebab50 | ||
|
|
4f5f3e14a1 | ||
|
|
dbf021e04a | ||
|
|
cc24f070db | ||
|
|
0b663a7fc6 | ||
|
|
fb8721bd4e | ||
|
|
ff1e4c1381 | ||
|
|
21bb20ce0c | ||
|
|
92b75cb924 | ||
|
|
1ea008b71c |
No files matched your search
@@ -1,4 +1,5 @@
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
.dockerignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
@@ -14,3 +15,10 @@ apps/nextjs/.next
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.docker-data
|
||||
.docker-cache
|
||||
coverage
|
||||
.cursor
|
||||
agent-transcripts
|
||||
**/*.spec.ts
|
||||
**/*.test.ts
|
||||
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.64.0
|
||||
- 1.62.0
|
||||
- 1.61.0
|
||||
- 1.60.1-beta.1
|
||||
|
||||
@@ -72,3 +72,7 @@ en.d.json.ts
|
||||
# personal agent config
|
||||
AGENTS.md
|
||||
.opencode
|
||||
|
||||
# local docker dev
|
||||
.docker-data/
|
||||
.docker-cache/
|
||||
@@ -1,3 +1,67 @@
|
||||
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
|
||||
|
||||
### Features
|
||||
|
||||
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
|
||||
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
|
||||
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
|
||||
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
|
||||
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
|
||||
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
|
||||
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
|
||||
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
|
||||
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
|
||||
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
|
||||
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
|
||||
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
|
||||
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
|
||||
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
|
||||
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
|
||||
|
||||
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
|
||||
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
|
||||
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
|
||||
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
|
||||
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
|
||||
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
|
||||
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
|
||||
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
|
||||
|
||||
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
|
||||
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
|
||||
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
|
||||
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
|
||||
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
|
||||
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
|
||||
|
||||
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
"@dnd-kit/modifiers": "catalog:",
|
||||
"@dnd-kit/sortable": "catalog:",
|
||||
"@dnd-kit/utilities": "catalog:",
|
||||
"@gfazioli/mantine-onboarding-tour": "catalog:",
|
||||
"@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 +65,6 @@
|
||||
"@trpc/next": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"@xterm/addon-canvas": "catalog:",
|
||||
"@xterm/addon-fit": "catalog:",
|
||||
"@xterm/xterm": "catalog:",
|
||||
"babel-plugin-react-compiler": "catalog:",
|
||||
|
||||
@@ -38,6 +38,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
@@ -57,9 +58,11 @@ export const BoardContentHeaderActions = () => {
|
||||
<EditModeMenu demoMode={demoMode ?? false} />
|
||||
|
||||
{!demoMode && (
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
@@ -195,9 +198,11 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -205,27 +210,31 @@ const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</OnboardingTour.Target>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -266,7 +275,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
};
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return; // Allow to reload in development
|
||||
if (env.NODE_ENV === "development") return;
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
@@ -20,14 +21,21 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
|
||||
if (!hasSession) return <>{children}</>;
|
||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
||||
};
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
withTour?: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour = false,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
const Layout = async ({
|
||||
params,
|
||||
@@ -61,14 +69,16 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
|
||||
@@ -6,11 +6,14 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { clearInitImportMode } from "./import/use-init-import-mode";
|
||||
|
||||
export const BackToStart = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
|
||||
|
||||
const handleBackToStartAsync = async () => {
|
||||
clearInitImportMode();
|
||||
await mutateAsync();
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
|
||||
|
||||
import { FileInfoCard } from "./file-info-card";
|
||||
|
||||
export const InitConfigImport = () => {
|
||||
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const hasOpenedRef = useRef(false);
|
||||
|
||||
const {
|
||||
mutate: runPreview,
|
||||
data: preview,
|
||||
isPending: previewLoading,
|
||||
reset: resetPreview,
|
||||
} = clientApi.import.previewInitialConfigImport.useMutation();
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.import.importInitialConfigImport.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOpenedRef.current) return;
|
||||
hasOpenedRef.current = true;
|
||||
setTimeout(() => inputRef.current?.click(), 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileContent) {
|
||||
resetPreview();
|
||||
return;
|
||||
}
|
||||
runPreview({ content: fileContent });
|
||||
}, [fileContent, runPreview, resetPreview]);
|
||||
|
||||
const importAllowed = preview?.compatibility.status === "compatible";
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file || !importAllowed) return;
|
||||
|
||||
await mutateAsync(
|
||||
{ content: fileContent },
|
||||
{
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("management.page.importExport.importConfig.notification.error.title"),
|
||||
message: t("management.page.importExport.importConfig.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
|
||||
void selectedFile.text().then((content) => {
|
||||
setFileContent(content);
|
||||
setFile(selectedFile as FileWithPath);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack w="100%" mb="sm">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{file && (
|
||||
<>
|
||||
<FileInfoCard
|
||||
file={file}
|
||||
onRemove={() => {
|
||||
setFile(null);
|
||||
setFileContent("");
|
||||
}}
|
||||
/>
|
||||
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
|
||||
<Button
|
||||
loading={isPending}
|
||||
disabled={!importAllowed || previewLoading}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{t("init.step.import.configImport.action")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!file && (
|
||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => inputRef.current?.click()}>
|
||||
{t("init.step.import.configImport.selectFile")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -11,8 +11,20 @@ import { InitialOldmarrImport } from "@homarr/old-import/components";
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { FileInfoCard } from "./file-info-card";
|
||||
import { ImportDropZone } from "./import-dropzone";
|
||||
import { InitConfigImport } from "./init-config-import";
|
||||
import { useInitImportMode } from "./use-init-import-mode";
|
||||
|
||||
export const InitImport = () => {
|
||||
const importMode = useInitImportMode();
|
||||
|
||||
if (importMode === "config") {
|
||||
return <InitConfigImport />;
|
||||
}
|
||||
|
||||
return <LegacyInitImport />;
|
||||
};
|
||||
|
||||
const LegacyInitImport = () => {
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
|
||||
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type InitImportMode = "legacy" | "config";
|
||||
|
||||
const storageKey = "homarr-init-import-mode";
|
||||
|
||||
const readMode = (): InitImportMode => {
|
||||
const stored = sessionStorage.getItem(storageKey);
|
||||
return stored === "config" ? "config" : "legacy";
|
||||
};
|
||||
|
||||
const subscribe = (onStoreChange: () => void) => {
|
||||
window.addEventListener("storage", onStoreChange);
|
||||
return () => window.removeEventListener("storage", onStoreChange);
|
||||
};
|
||||
|
||||
export const setInitImportMode = (mode: InitImportMode) => {
|
||||
sessionStorage.setItem(storageKey, mode);
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
};
|
||||
|
||||
export const clearInitImportMode = () => {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
};
|
||||
|
||||
export const useInitImportMode = () =>
|
||||
useSyncExternalStore(subscribe, readMode, () => "legacy" as InitImportMode);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { IconFileImport, IconJson, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
@@ -23,10 +23,18 @@ export const InitStart = async () => {
|
||||
</InitStartButton>
|
||||
<InitStartButton
|
||||
preferredStep="import"
|
||||
importMode="legacy"
|
||||
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.importOldmarr")}
|
||||
</InitStartButton>
|
||||
<InitStartButton
|
||||
preferredStep="import"
|
||||
importMode="config"
|
||||
icon={<IconJson color={getMantineColor("blue", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.importConfig")}
|
||||
</InitStartButton>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
|
||||
@@ -7,15 +7,28 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { clearInitImportMode, setInitImportMode, type InitImportMode } from "../import/use-init-import-mode";
|
||||
|
||||
interface InitStartButtonProps {
|
||||
icon: ReactNode;
|
||||
preferredStep: OnboardingStep | undefined;
|
||||
importMode?: InitImportMode;
|
||||
}
|
||||
|
||||
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
|
||||
export const InitStartButton = ({
|
||||
preferredStep,
|
||||
icon,
|
||||
children,
|
||||
importMode,
|
||||
}: PropsWithChildren<InitStartButtonProps>) => {
|
||||
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
if (importMode) {
|
||||
setInitImportMode(importMode);
|
||||
} else {
|
||||
clearInitImportMode();
|
||||
}
|
||||
await mutateAsync({ preferredStep });
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import "@gfazioli/mantine-onboarding-tour/styles.css";
|
||||
import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
import "@homarr/ui/styles.css";
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
@@ -45,9 +46,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
<TourTarget id="manage-apps-create">
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
@@ -56,11 +59,13 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
<TourTarget id="manage-apps-list">
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
</TourTarget>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
+19
-3
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDeviceMobile, IconDownload, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { DuplicateBoardModal, ExportBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
@@ -28,7 +28,10 @@ interface BoardCardMenuDropdownProps {
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
const t = useScopedI18n("management.page.board.action");
|
||||
const t = useScopedI18n("management.page.board.action" as never) as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, unknown>,
|
||||
) => string;
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
@@ -36,6 +39,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
||||
const { openModal: openExportModal } = useModalAction(ExportBoardModal);
|
||||
|
||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
@@ -87,6 +91,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
});
|
||||
}, [board.id, board.name, openDuplicateModal]);
|
||||
|
||||
const handleExportBoard = useCallback(() => {
|
||||
openExportModal({
|
||||
board: {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, openExportModal]);
|
||||
|
||||
return (
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||
@@ -95,6 +108,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
|
||||
{t("setMobileHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={handleExportBoard} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("export.label")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("duplicate.label")}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
const t = useI18n() as unknown as (key: string) => string;
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
@@ -42,18 +43,26 @@ export default async function ManageBoardsPage() {
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
|
||||
primaryAction={
|
||||
canCreateBoards ? (
|
||||
<TourTarget id="manage-boards-create">
|
||||
<CreateBoardButton />
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||
{boards.length > 0 && (
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
<TourTarget id="manage-boards-list">
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
</TourTarget>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
@@ -60,14 +61,18 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
canCreateIntegrations ? (
|
||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||
{t("action.create")}
|
||||
</MobileAffixButton>
|
||||
<TourTarget id="manage-integrations-create">
|
||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||
{t("action.create")}
|
||||
</MobileAffixButton>
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateIntegrations}
|
||||
>
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
<TourTarget id="manage-integrations-list">
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
</TourTarget>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconBrandTablerFilled,
|
||||
IconCertificate,
|
||||
IconClipboardListFilled,
|
||||
IconDatabaseExport,
|
||||
IconDirectionsFilled,
|
||||
IconGitFork,
|
||||
IconHelpSquareRoundedFilled,
|
||||
@@ -36,6 +37,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||
|
||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
const t = await getScopedI18n("management.navbar");
|
||||
@@ -45,11 +47,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.home"),
|
||||
icon: IconHomeFilled,
|
||||
href: "/manage",
|
||||
"data-onboarding-tour-id": "manage-welcome",
|
||||
},
|
||||
{
|
||||
icon: IconLayoutDashboardFilled,
|
||||
href: "/manage/boards",
|
||||
label: t("items.boards"),
|
||||
"data-onboarding-tour-id": "manage-boards",
|
||||
},
|
||||
{
|
||||
icon: IconBox,
|
||||
@@ -59,12 +63,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
"data-onboarding-tour-id": "manage-apps",
|
||||
},
|
||||
{
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
@@ -74,17 +80,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
"data-onboarding-tour-id": "manage-search-engines",
|
||||
},
|
||||
{
|
||||
icon: IconPhotoFilled,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session,
|
||||
"data-onboarding-tour-id": "manage-medias",
|
||||
},
|
||||
{
|
||||
icon: IconUserFilled,
|
||||
label: t("items.users.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
"data-onboarding-tour-id": "manage-users",
|
||||
items: [
|
||||
{
|
||||
label: t("items.users.items.manage"),
|
||||
@@ -146,6 +155,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/tools/tasks",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
|
||||
icon: IconDatabaseExport,
|
||||
href: "/manage/tools/import-export",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -153,6 +168,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/settings",
|
||||
icon: IconSettingsFilled,
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
"data-onboarding-tour-id": "manage-settings",
|
||||
},
|
||||
{
|
||||
label: t("items.help.label"),
|
||||
@@ -191,11 +207,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
|
||||
const shell = (
|
||||
<ClientShell hasNavigation>
|
||||
<MainHeader></MainHeader>
|
||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
if (!session) return shell;
|
||||
|
||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { NumberInput, Switch, TextInput } from "@mantine/core";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["docker"] }) => {
|
||||
const tDocker = useScopedI18n("management.page.settings.section.docker" as never) as unknown as (
|
||||
key: string,
|
||||
) => string;
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="docker" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<TextInput
|
||||
{...form.getInputProps("targetBoardName")}
|
||||
value={form.getInputProps("targetBoardName").value ?? ""}
|
||||
label={tDocker("targetBoardName.label")}
|
||||
description={tDocker("targetBoardName.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("readHomepageLabels", { type: "checkbox" })}
|
||||
label={tDocker("readHomepageLabels.label")}
|
||||
description={tDocker("readHomepageLabels.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
|
||||
label={tDocker("createIntegrations.label")}
|
||||
description={tDocker("createIntegrations.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("pruneRemoved", { type: "checkbox" })}
|
||||
label={tDocker("pruneRemoved.label")}
|
||||
description={tDocker("pruneRemoved.description")}
|
||||
/>
|
||||
<NumberInput
|
||||
{...form.getInputProps("defaultItemWidth")}
|
||||
label={tDocker("defaultItemWidth.label")}
|
||||
description={tDocker("defaultItemWidth.description")}
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
<NumberInput
|
||||
{...form.getInputProps("defaultItemHeight")}
|
||||
label={tDocker("defaultItemHeight.label")}
|
||||
description={tDocker("defaultItemHeight.description")}
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
@@ -5,12 +5,15 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||
import { DockerSettingsForm } from "./_components/docker-settings-form";
|
||||
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||
import { UserSettingsForm } from "./_components/user-settings-form";
|
||||
|
||||
@@ -59,6 +62,12 @@ export default async function SettingsPage() {
|
||||
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||
</Stack>
|
||||
{env.ENABLE_DOCKER && (
|
||||
<Stack>
|
||||
<Title order={2}>{(tSettings as unknown as (key: string) => string)("section.docker.title")}</Title>
|
||||
<DockerSettingsForm defaultValues={serverSettings.docker} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconCategoryPlus,
|
||||
IconDots,
|
||||
IconFileText,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconRefresh,
|
||||
IconRotateClockwise,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
|
||||
import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap } from "@homarr/docker/shared";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -27,25 +30,30 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { OverflowBadge } from "@homarr/ui";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
const createColumns = (
|
||||
t: TranslationFunction,
|
||||
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||
|
||||
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("docker.field.name.label"),
|
||||
Cell({ renderedCellValue, row }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius="lg"
|
||||
radius="sm"
|
||||
size="md"
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={row.original.iconUrl}
|
||||
>
|
||||
{row.original.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Text>{renderedCellValue}</Text>
|
||||
<Text truncate="end" maw={500}>
|
||||
{renderedCellValue}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -76,11 +84,11 @@ const createColumns = (
|
||||
maxSize: 200,
|
||||
Cell({ renderedCellValue, cell }) {
|
||||
return (
|
||||
<Box maw={200}>
|
||||
<Text truncate="end" title={cell.row.original.image}>
|
||||
{renderedCellValue}
|
||||
</Text>
|
||||
</Box>
|
||||
<Tooltip label={cell.row.original.image} multiline maw={400}>
|
||||
<Box maw={200}>
|
||||
<Text truncate="end">{renderedCellValue}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -95,13 +103,57 @@ const createColumns = (
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpuUsage",
|
||||
accessorKey: "cpuUsage",
|
||||
size: 120,
|
||||
header: t("docker.field.stats.cpu.label"),
|
||||
enableHiding: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const cpuUsageA = safeValue(rowA.original.cpuUsage);
|
||||
const cpuUsageB = safeValue(rowB.original.cpuUsage);
|
||||
|
||||
return cpuUsageA - cpuUsageB;
|
||||
},
|
||||
Cell({ row }) {
|
||||
const cpuUsage = safeValue(row.original.cpuUsage);
|
||||
|
||||
return (
|
||||
<Text size="sm" c={cpuUsageColor(cpuUsage, row.original.state)}>
|
||||
{cpuUsage.toFixed(2)}%
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memoryUsage",
|
||||
accessorKey: "memoryUsage",
|
||||
size: 140,
|
||||
header: t("docker.field.stats.memory.label"),
|
||||
enableHiding: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const memoryUsageA = safeValue(rowA.original.memoryUsage);
|
||||
const memoryUsageB = safeValue(rowB.original.memoryUsage);
|
||||
|
||||
return memoryUsageA - memoryUsageB;
|
||||
},
|
||||
Cell({ row }) {
|
||||
const bytesUsage = safeValue(row.original.memoryUsage);
|
||||
|
||||
return (
|
||||
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||
{humanFileSize(bytesUsage)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
|
||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
initialData: { containers, timestamp },
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
@@ -131,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
enableColumnFilters: false,
|
||||
enablePagination: false,
|
||||
enableRowSelection: true,
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: "last",
|
||||
positionToolbarAlertBanner: "top",
|
||||
enableTableFooter: false,
|
||||
enableBottomToolbar: false,
|
||||
@@ -141,7 +195,10 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
autoFocus: true,
|
||||
},
|
||||
|
||||
initialState: { density: "xs", showGlobalFilter: true },
|
||||
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
|
||||
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
|
||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||
{tDocker("action.refresh.label")}
|
||||
@@ -174,28 +231,104 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
);
|
||||
}
|
||||
|
||||
const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [
|
||||
{ action: "start", icon: IconPlayerPlay, color: "green" },
|
||||
{ action: "stop", icon: IconPlayerStop, color: "red" },
|
||||
{ action: "restart", icon: IconRotateClockwise, color: "orange" },
|
||||
{ action: "remove", icon: IconTrash, color: "red" },
|
||||
];
|
||||
|
||||
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
|
||||
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
|
||||
clientApi.docker[`${action}All`].useMutation({
|
||||
async onSettled() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${action}.notification.success.title`),
|
||||
message: t(`${action}.notification.success.message`),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t(`${action}.notification.error.title`),
|
||||
message: t(`${action}.notification.error.message`),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mutations = {
|
||||
start: useContainerAction("start"),
|
||||
stop: useContainerAction("stop"),
|
||||
restart: useContainerAction("restart"),
|
||||
remove: useContainerAction("remove"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={(e) => e.stopPropagation()}>
|
||||
<IconDots size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size="1rem" />}
|
||||
onClick={() => router.push(createContainerLogsPath(container))}
|
||||
>
|
||||
{t("logs.label")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{containerActions.map(({ action, icon: Icon, color }) => (
|
||||
<Menu.Item
|
||||
key={action}
|
||||
leftSection={<Icon size="1rem" />}
|
||||
color={color}
|
||||
onClick={() => mutations[action].mutate({ ids: [container.id] })}
|
||||
>
|
||||
{t(`${action}.label`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconCategoryPlus size="1rem" />}
|
||||
color="red"
|
||||
onClick={() => openModal({ selectedContainers: [container] })}
|
||||
>
|
||||
{t("addToHomarr.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContainerActionBarProps {
|
||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
selectedContainers: DockerContainer[];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const handleClick = () => {
|
||||
openModal({
|
||||
selectedContainers,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedIds = selectedContainers.map((container) => container.id);
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
|
||||
{containerActions.map(({ action, icon, color }) => (
|
||||
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
|
||||
))}
|
||||
<Button
|
||||
leftSection={<IconCategoryPlus />}
|
||||
color="red"
|
||||
onClick={() => openModal({ selectedContainers })}
|
||||
variant="light"
|
||||
>
|
||||
{t("addToHomarr.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const ClientSideDockerLogsTerminal = dynamic(
|
||||
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Box, Group } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { ClientSideDockerLogsTerminal } from "./client";
|
||||
|
||||
interface DockerContainerLogsPageProps {
|
||||
params: Promise<{ containerId: string }>;
|
||||
searchParams: Promise<{ name?: string }>;
|
||||
}
|
||||
|
||||
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { containerId } = await params;
|
||||
const { name } = await searchParams;
|
||||
|
||||
try {
|
||||
await api.docker.logs({ id: containerId, tail: 1 });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<DynamicBreadcrumb
|
||||
dynamicMappings={new Map([[containerId, name ?? containerId]])}
|
||||
nonInteractable={[containerId]}
|
||||
/>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideDockerLogsTerminal containerId={containerId} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.outerTerminal > div {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import classes from "./terminal.module.css";
|
||||
|
||||
interface DockerLogsTerminalProps {
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const pendingLogsRef = useRef<string[]>([]);
|
||||
const isTerminalReadyRef = useRef(false);
|
||||
|
||||
const writeToTerminal = useCallback((data: string) => {
|
||||
if (!isTerminalReadyRef.current || !terminalRef.current) {
|
||||
pendingLogsRef.current.push(data);
|
||||
return;
|
||||
}
|
||||
|
||||
terminalRef.current.write(data);
|
||||
terminalRef.current.refresh(0, terminalRef.current.rows - 1);
|
||||
}, []);
|
||||
|
||||
clientApi.docker.subscribeLogs.useSubscription(
|
||||
{ id: containerId, tail: 200 },
|
||||
{
|
||||
onData: writeToTerminal,
|
||||
onError(err) {
|
||||
writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
});
|
||||
terminal.open(ref.current);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminalRef.current = terminal;
|
||||
|
||||
isTerminalReadyRef.current = true;
|
||||
pendingLogsRef.current.forEach((data) => terminal.write(data));
|
||||
pendingLogsRef.current = [];
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
|
||||
const fitTimeout = setTimeout(() => fitAddon.fit());
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
|
||||
resizeObserver.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
clearTimeout(fitTimeout);
|
||||
resizeObserver.disconnect();
|
||||
isTerminalReadyRef.current = false;
|
||||
pendingLogsRef.current = [];
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [writeToTerminal]);
|
||||
|
||||
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Badge, Button, Card, Group, Stack, Table, Text, Title } from "@mantine/core";
|
||||
import { IconRefresh, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const DockerDiscoveryPanel = () => {
|
||||
const tDiscovery = useScopedI18n("docker.discovery" as never) as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, unknown>,
|
||||
) => string;
|
||||
const { data: preview, refetch, isLoading: isLoadingPreview, isFetched } =
|
||||
clientApi.docker.getDiscoveredPreview.useQuery(undefined, {
|
||||
enabled: false,
|
||||
});
|
||||
const { mutate: syncDiscovery, isPending: isSyncing } = clientApi.docker.syncDiscovery.useMutation({
|
||||
onSuccess(result) {
|
||||
showSuccessNotification({
|
||||
title: tDiscovery("sync.notification.success.title"),
|
||||
message: tDiscovery("sync.notification.success.message", {
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
}),
|
||||
});
|
||||
void refetch();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDiscovery("sync.notification.error.title"),
|
||||
message: tDiscovery("sync.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const services = preview ?? [];
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>{tDiscovery("title")}</Title>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
loading={isLoadingPreview}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{tDiscovery("preview.label")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
loading={isSyncing}
|
||||
onClick={() => syncDiscovery()}
|
||||
disabled={services.length === 0}
|
||||
>
|
||||
{tDiscovery("sync.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!isFetched ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{tDiscovery("preview.idle")}
|
||||
</Text>
|
||||
) : services.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{tDiscovery("preview.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{tDiscovery("field.name")}</Table.Th>
|
||||
<Table.Th>{tDiscovery("field.group")}</Table.Th>
|
||||
<Table.Th>{tDiscovery("field.href")}</Table.Th>
|
||||
<Table.Th>{tDiscovery("field.host")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{services.map((service) => (
|
||||
<Table.Tr key={`${service.host}/${service.containerId}`}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{service.icon && (
|
||||
<Avatar size="sm" radius="sm" src={service.icon}>
|
||||
{service.name.charAt(0)}
|
||||
</Avatar>
|
||||
)}
|
||||
<Text size="sm">{service.name}</Text>
|
||||
{service.integrationKind && (
|
||||
<Badge size="xs" variant="light">
|
||||
{service.integrationKind}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="outline" size="sm">
|
||||
{service.group}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{service.href}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{service.host}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Card, Group, List, Loader, Popover, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconDatabaseExport } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ConfigSummaryList } from "~/components/config-import/config-summary-list";
|
||||
|
||||
const exportWarningPointKeys = [
|
||||
"management.page.importExport.export.warning.message",
|
||||
"management.page.importExport.export.warning.storage",
|
||||
"management.page.importExport.export.warning.sharing",
|
||||
] as const;
|
||||
|
||||
export const ExportConfigCard = () => {
|
||||
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
|
||||
const { mutateAsync, isPending } = clientApi.config.exportFull.useMutation();
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
|
||||
const { data: preview, isLoading: previewLoading } = clientApi.config.previewExport.useQuery(undefined, {
|
||||
enabled: popoverOpened,
|
||||
});
|
||||
|
||||
const closePopover = () => setPopoverOpened(false);
|
||||
|
||||
const runExport = async () => {
|
||||
await mutateAsync(undefined, {
|
||||
onSuccess({ content, filename }) {
|
||||
const blob = new Blob([content], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showSuccessNotification({
|
||||
title: t("management.page.importExport.export.notification.success.title"),
|
||||
message: t("management.page.importExport.export.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("management.page.importExport.export.notification.error.title"),
|
||||
message: t("management.page.importExport.export.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmExport = () => {
|
||||
closePopover();
|
||||
void runExport();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>{t("management.page.importExport.export.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.export.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Popover
|
||||
opened={popoverOpened}
|
||||
onChange={setPopoverOpened}
|
||||
width={420}
|
||||
position="bottom-end"
|
||||
withArrow
|
||||
shadow="md"
|
||||
>
|
||||
<Popover.Target>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconDatabaseExport size="1rem" />}
|
||||
loading={isPending}
|
||||
disabled={isPending}
|
||||
onClick={() => setPopoverOpened(true)}
|
||||
>
|
||||
{t("management.page.importExport.export.action")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size="1.25rem" stroke={1.5} />}
|
||||
title={t("management.page.importExport.export.warning.title")}
|
||||
>
|
||||
<List size="sm" spacing="xs">
|
||||
{exportWarningPointKeys.map((key) => (
|
||||
<List.Item key={key}>{t(key)}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("management.page.importExport.summary.title")}
|
||||
</Text>
|
||||
{previewLoading && (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.preview.exportLoading")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{preview && <ConfigSummaryList counts={preview} t={t} />}
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closePopover}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red.9"
|
||||
onClick={handleConfirmExport}
|
||||
disabled={previewLoading}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("common.action.continue")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Card, FileInput, Group, List, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconCheck, IconFileUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
|
||||
|
||||
export const ImportConfigCard = () => {
|
||||
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [importResult, setImportResult] = useState<{
|
||||
boards: number;
|
||||
apps: number;
|
||||
integrations: number;
|
||||
groups: number;
|
||||
searchEngines: number;
|
||||
warnings: string[];
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
mutate: runPreview,
|
||||
data: preview,
|
||||
isPending: previewLoading,
|
||||
reset: resetPreview,
|
||||
} = clientApi.config.previewImport.useMutation();
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.config.importFull.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileContent) {
|
||||
resetPreview();
|
||||
return;
|
||||
}
|
||||
runPreview({ content: fileContent });
|
||||
}, [fileContent, runPreview, resetPreview]);
|
||||
|
||||
const importAllowed = preview?.compatibility.status === "compatible";
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file || !importAllowed) {
|
||||
return;
|
||||
}
|
||||
const content = await file.text();
|
||||
await mutateAsync(
|
||||
{ content },
|
||||
{
|
||||
onSuccess(result) {
|
||||
setImportResult(result);
|
||||
showSuccessNotification({
|
||||
title: t("management.page.importExport.importConfig.notification.success.title"),
|
||||
message: t("management.page.importExport.importConfig.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("management.page.importExport.importConfig.notification.error.title"),
|
||||
message: t("management.page.importExport.importConfig.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>{t("management.page.importExport.importConfig.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.importConfig.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
accept="application/json"
|
||||
value={file}
|
||||
onChange={(newFile) => {
|
||||
setFile(newFile);
|
||||
setImportResult(null);
|
||||
setFileContent("");
|
||||
|
||||
if (!newFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
void newFile.text().then((content) => {
|
||||
setFileContent(content);
|
||||
});
|
||||
}}
|
||||
label={t("management.page.importExport.importConfig.fileLabel")}
|
||||
/>
|
||||
|
||||
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
|
||||
|
||||
{importResult && (
|
||||
<Alert color="green" icon={<IconCheck />}>
|
||||
<List size="sm">
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.boardsImported", { count: importResult.boards })}</List.Item>
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.appsCreated", { count: importResult.apps })}</List.Item>
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.integrationsCreated", { count: importResult.integrations })}</List.Item>
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.groupsCreated", { count: importResult.groups })}</List.Item>
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="end">
|
||||
<Button loading={isPending} disabled={!file || !importAllowed || previewLoading} onClick={() => void handleImport()}>
|
||||
{t("common.action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Card, Code, FileInput, Group, Stack, Switch, Text, TextInput, Title } from "@mantine/core";
|
||||
import { IconFileUpload } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
extractHomepageEnvVariables,
|
||||
parseServicesYaml,
|
||||
replaceHomepageEnvVariables,
|
||||
} from "@homarr/board-portability/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardNameSchema } from "@homarr/validation/board";
|
||||
|
||||
export const ImportHomepageCard = () => {
|
||||
const t = useI18n() as unknown as (key: string) => string;
|
||||
const [fileValid, setFileValid] = useState(true);
|
||||
const [rawContent, setRawContent] = useState("");
|
||||
const [envVars, setEnvVars] = useState<string[]>([]);
|
||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.file(),
|
||||
boardName: boardNameSchema,
|
||||
createIntegrations: z.boolean(),
|
||||
}),
|
||||
{
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
boardName: "",
|
||||
createIntegrations: true,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
void (async () => {
|
||||
if (values.file === previous.file) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!values.file) return;
|
||||
|
||||
const content = await values.file.text();
|
||||
setRawContent(content);
|
||||
|
||||
const vars = extractHomepageEnvVariables(content);
|
||||
setEnvVars(vars);
|
||||
setEnvValues(Object.fromEntries(vars.map((v: string) => [v, ""])));
|
||||
|
||||
const result = parseServicesYaml(content);
|
||||
if (!result.success) {
|
||||
setFileValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFileValid(true);
|
||||
const suggestedName =
|
||||
result.services[0]?.group.replaceAll(/[^A-Za-z0-9-_]/g, "-").replaceAll(/-+/g, "-") ?? "homepage";
|
||||
if (form.values.boardName.trim().length === 0) {
|
||||
form.setFieldValue("boardName", suggestedName.slice(0, 255));
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.importHomepageServices.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
|
||||
const allEnvVarsFilled = envVars.length === 0 || envVars.every((v) => (envValues[v] ?? "").trim().length > 0);
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
if (!fileValid || !allEnvVarsFilled) return;
|
||||
|
||||
void (async () => {
|
||||
const resolvedContent =
|
||||
envVars.length > 0 ? replaceHomepageEnvVariables(rawContent, envValues) : rawContent;
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
content: resolvedContent,
|
||||
boardName: values.boardName,
|
||||
createIntegrations: values.createIntegrations,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("management.page.importExport.importHomepage.notification.success.title"),
|
||||
message: t("management.page.importExport.importHomepage.notification.success.message"),
|
||||
});
|
||||
form.reset();
|
||||
setRawContent("");
|
||||
setEnvVars([]);
|
||||
setEnvValues({});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("management.page.importExport.importHomepage.notification.error.title"),
|
||||
message: t("management.page.importExport.importHomepage.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
})();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>{t("management.page.importExport.importHomepage.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.importHomepage.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
withAsterisk
|
||||
accept=".yaml,.yml"
|
||||
{...form.getInputProps("file")}
|
||||
error={
|
||||
(form.getInputProps("file").error as string | undefined) ??
|
||||
(!fileValid && form.isDirty("file") ? t("management.page.importExport.importHomepage.invalidFile") : undefined)
|
||||
}
|
||||
type="button"
|
||||
label={t("management.page.importExport.importHomepage.fileLabel")}
|
||||
/>
|
||||
|
||||
{envVars.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("management.page.importExport.importHomepage.envVariables.label")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("management.page.importExport.importHomepage.envVariables.description")}
|
||||
</Text>
|
||||
{envVars.map((varName) => (
|
||||
<TextInput
|
||||
key={varName}
|
||||
label={<Code>{`{{${varName}}}`}</Code>}
|
||||
placeholder={varName.replace("HOMEPAGE_VAR_", "").toLowerCase().replaceAll("_", ".")}
|
||||
value={envValues[varName] ?? ""}
|
||||
onChange={(event) => {
|
||||
setEnvValues((prev) => ({ ...prev, [varName]: event.target.value }));
|
||||
}}
|
||||
withAsterisk
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={t("management.page.importExport.importHomepage.boardNameLabel")}
|
||||
{...form.getInputProps("boardName")}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t("management.page.importExport.importHomepage.createIntegrations")}
|
||||
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} disabled={!allEnvVarsFilled}>
|
||||
{t("common.action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { DockerDiscoveryPanel } from "./docker-discovery-panel";
|
||||
import { ExportConfigCard } from "./export-config-card";
|
||||
import { ImportConfigCard } from "./import-config-card";
|
||||
import { ImportHomepageCard } from "./import-homepage-card";
|
||||
|
||||
export default async function ImportExportPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.importExport" as never) as unknown as (key: string) => string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<ExportConfigCard />
|
||||
<ImportConfigCard />
|
||||
<ImportHomepageCard />
|
||||
{env.ENABLE_DOCKER && <DockerDiscoveryPanel />}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
@@ -15,8 +14,8 @@ export const TerminalComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { activeLevels, fontSize } = useLogContext();
|
||||
|
||||
const terminalRef = useRef<Terminal>(null);
|
||||
const fitAddonRef = useRef<FitAddon>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
|
||||
clientApi.log.subscribe.useSubscription(
|
||||
{
|
||||
@@ -38,27 +37,31 @@ export const TerminalComponent = () => {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const canvasAddon = new CanvasAddon();
|
||||
|
||||
terminalRef.current = new Terminal({
|
||||
const fitAddon = new FitAddon();
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize,
|
||||
});
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
terminal.open(ref.current);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminalRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
});
|
||||
const fitTerminal = () => fitAddon.fit();
|
||||
const fitTimeout = setTimeout(fitTerminal);
|
||||
|
||||
const resizeObserver = new ResizeObserver(fitTerminal);
|
||||
resizeObserver.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
terminalRef.current?.dispose();
|
||||
canvasAddon.dispose();
|
||||
clearTimeout(fitTimeout);
|
||||
resizeObserver.disconnect();
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const ResetTours = () => {
|
||||
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("resetSuccess.title"),
|
||||
message: t("resetSuccess.message"),
|
||||
});
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const tours = [
|
||||
{ key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
|
||||
{ key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
{tours.map(({ key, completed }) => (
|
||||
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
|
||||
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
w="fit-content"
|
||||
onClick={() => resetTours()}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { canAccessUserEditPage } from "../access";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||
import { ResetTours } from "./_components/_reset-tours";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
@@ -87,6 +88,13 @@ export default async function EditUserPage(props: Props) {
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{session?.user.id === user.id && (
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
|
||||
<ResetTours />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={t("user.action.delete.label")}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
const session = await auth();
|
||||
const t = await getI18n();
|
||||
const tUser = await getScopedI18n("management.page.user");
|
||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
|
||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||
|
||||
@@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) {
|
||||
.getById({
|
||||
userId: params.userId,
|
||||
})
|
||||
.catch(catchTrpcNotFound);
|
||||
.catch(catchTrpcNotFound)
|
||||
.catch(catchTrpcUnauthorized);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
@@ -38,14 +39,18 @@ export default async function UsersPage() {
|
||||
title={t("list.title")}
|
||||
primaryAction={
|
||||
credentialsProviderEnabled ? (
|
||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||
{t("create.title")}
|
||||
</MobileAffixButton>
|
||||
<TourTarget id="manage-users-create">
|
||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||
{t("create.title")}
|
||||
</MobileAffixButton>
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={credentialsProviderEnabled}
|
||||
>
|
||||
<UserListComponent initialUserList={userList} />
|
||||
<TourTarget id="manage-users-list">
|
||||
<UserListComponent initialUserList={userList} />
|
||||
</TourTarget>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Group, List, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { ConfigImportPreview } from "@homarr/board-portability";
|
||||
|
||||
import { ConfigSummaryList } from "./config-summary-list";
|
||||
|
||||
const compatibilityAlertColor: Record<ConfigImportPreview["compatibility"]["status"], string> = {
|
||||
compatible: "blue",
|
||||
unsupportedVersion: "red",
|
||||
invalidStructure: "red",
|
||||
};
|
||||
|
||||
type CountSection = { labelKey: string; entries: { key: string; count: number }[] };
|
||||
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const buildCountSections = (preview: ConfigImportPreview): CountSection[] => [
|
||||
{
|
||||
labelKey: "management.page.importExport.preview.importWillReuse",
|
||||
entries: Object.entries(preview.toReuse)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => ({ key: `management.page.importExport.preview.reuse${capitalize(key)}`, count })),
|
||||
},
|
||||
{
|
||||
labelKey: "management.page.importExport.preview.importWillSkip",
|
||||
entries: Object.entries(preview.toSkip)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => ({ key: `management.page.importExport.preview.skip${capitalize(key)}`, count })),
|
||||
},
|
||||
{
|
||||
labelKey: "management.page.importExport.preview.importWillUpdate",
|
||||
entries: Object.entries(preview.toUpdate)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => ({ key: `management.page.importExport.preview.update${capitalize(key)}`, count })),
|
||||
},
|
||||
];
|
||||
|
||||
type ConfigImportPreviewPanelProps = {
|
||||
preview: ConfigImportPreview | undefined;
|
||||
previewLoading: boolean;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
export const ConfigImportPreviewPanel = ({ preview, previewLoading, t }: ConfigImportPreviewPanelProps) => {
|
||||
if (!preview && !previewLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const importAllowed = preview?.compatibility.status === "compatible";
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{previewLoading && (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.preview.importLoading")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<Alert
|
||||
variant="light"
|
||||
color={compatibilityAlertColor[preview.compatibility.status]}
|
||||
icon={<IconAlertTriangle size="1rem" stroke={1.5} />}
|
||||
title={
|
||||
preview.compatibility.status === "compatible"
|
||||
? t("management.page.importExport.preview.migrationNote", {
|
||||
bundleVersion: preview.meta.bundleVersion || "?",
|
||||
})
|
||||
: t("management.page.importExport.preview.importIncompatible")
|
||||
}
|
||||
>
|
||||
{preview.compatibility.status === "compatible" && preview.meta.exportedAt && (
|
||||
<Text size="sm" mb="xs">
|
||||
{t("management.page.importExport.preview.importMeta", {
|
||||
exportedAt: preview.meta.exportedAt,
|
||||
homarrVersion: preview.meta.homarrVersion,
|
||||
bundleVersion: preview.meta.bundleVersion,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
{preview.compatibility.issues.length > 0 && (
|
||||
<List size="sm" spacing={4}>
|
||||
{preview.compatibility.issues.map((issue) => (
|
||||
<List.Item key={issue}>{issue}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{importAllowed && (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("management.page.importExport.preview.importWillCreate")}
|
||||
</Text>
|
||||
<ConfigSummaryList counts={preview.toCreate} t={t} />
|
||||
</Stack>
|
||||
|
||||
{buildCountSections(preview)
|
||||
.filter((section) => section.entries.length > 0)
|
||||
.map((section) => (
|
||||
<Stack gap="xs" key={section.labelKey}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t(section.labelKey)}
|
||||
</Text>
|
||||
<List size="sm">
|
||||
{section.entries.map((entry) => (
|
||||
<List.Item key={entry.key}>
|
||||
{t(entry.key, { count: entry.count })}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
{preview.warnings.length > 0 && (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="1rem" />}>
|
||||
<List size="sm">
|
||||
{preview.warnings.map((warning) => (
|
||||
<List.Item key={warning}>{warning}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { List, Text } from "@mantine/core";
|
||||
|
||||
import type { ConfigEntityCounts, ConfigEntityKey } from "@homarr/board-portability";
|
||||
import { CONFIG_ENTITY_KEYS } from "@homarr/board-portability";
|
||||
|
||||
const TRANSLATION_PREFIX = "management.page.importExport.summary";
|
||||
|
||||
type ConfigSummaryListProps = {
|
||||
counts: ConfigEntityCounts;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
|
||||
const entries = CONFIG_ENTITY_KEYS.filter((key) => counts[key] > 0);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`${TRANSLATION_PREFIX}.empty`)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List size="sm" spacing={4}>
|
||||
{entries.map((key: ConfigEntityKey) => (
|
||||
<List.Item key={key}>
|
||||
{t(`${TRANSLATION_PREFIX}.${key}`, { count: counts[key] })}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
import { UserButton } from "./header/user";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
|
||||
@@ -25,11 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<DesktopSearchInput />
|
||||
<TourTarget id="board-search">
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<UserButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<Spotlight />
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
|
||||
<OnboardingTour.Target id={id}>
|
||||
<Box data-tour-target={id}>{children}</Box>
|
||||
</OnboardingTour.Target>
|
||||
);
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { NavLink } from "@mantine/core";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
@@ -9,9 +10,15 @@ import { Link } from "@homarr/ui";
|
||||
export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
||||
|
||||
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
||||
if (!id) return <>{children}</>;
|
||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||
};
|
||||
|
||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
const pathname = usePathname();
|
||||
return props.external ? (
|
||||
const tourId = props["data-onboarding-tour-id"];
|
||||
const link = props.external ? (
|
||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||
) : (
|
||||
<NavLink
|
||||
@@ -19,26 +26,29 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
active={pathname === props.href}
|
||||
active={pathname === props.href || pathname.startsWith(`${props.href}/`)}
|
||||
/>
|
||||
);
|
||||
return <TourTarget id={tourId}>{link}</TourTarget>;
|
||||
};
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = props.items.some((item) => item.href === pathname);
|
||||
return (
|
||||
const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
|
||||
const nav = (
|
||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
||||
};
|
||||
|
||||
interface CommonNavigationLinkProps {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
"data-onboarding-tour-id"?: string;
|
||||
}
|
||||
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
|
||||
@@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
||||
return null;
|
||||
}
|
||||
|
||||
const { icon: TablerIcon, iconProps, ...props } = link;
|
||||
const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link;
|
||||
const Icon =
|
||||
typeof TablerIcon === "string" ? (
|
||||
<Image src={TablerIcon} w={20} h={20} />
|
||||
@@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
||||
if ("items" in props) {
|
||||
clientLink = {
|
||||
...props,
|
||||
"data-onboarding-tour-id": tourId,
|
||||
items: props.items
|
||||
.filter((item) => !item.hidden)
|
||||
.map((item) => {
|
||||
const { "data-onboarding-tour-id": itemTourId, ...rest } = item;
|
||||
return {
|
||||
...item,
|
||||
...rest,
|
||||
"data-onboarding-tour-id": itemTourId,
|
||||
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
|
||||
};
|
||||
}),
|
||||
} as ClientNavigationLink;
|
||||
} else {
|
||||
clientLink = props as ClientNavigationLink;
|
||||
clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink;
|
||||
}
|
||||
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
|
||||
})}
|
||||
@@ -63,6 +66,7 @@ interface CommonNavigationLinkProps {
|
||||
icon: TablerIcon | string;
|
||||
iconProps?: TablerIconProps;
|
||||
hidden?: boolean;
|
||||
"data-onboarding-tour-id"?: string;
|
||||
}
|
||||
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
|
||||
@@ -58,19 +58,23 @@ export const DynamicBreadcrumb = ({
|
||||
}
|
||||
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
|
||||
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
|
||||
const mappedValue = dynamicMappings?.get(pathnamePart);
|
||||
|
||||
if (mappedValue) {
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{mappedValue}</Text>;
|
||||
}
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{mappedValue}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
|
||||
}
|
||||
|
||||
if (dynamicMappings?.has(pathnamePart)) {
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{dynamicMappings.get(pathnamePart)}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{t(`${translationKey}.label` as TranslationKeys)}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { TourShell } from "./tour-shell";
|
||||
import { TourStepContent } from "./tour-step-content";
|
||||
|
||||
export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useScopedI18n("onboardingTour.board");
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||
onSuccess() {
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
|
||||
|
||||
const handleEnd = () => {
|
||||
utils.user.getTourStatus.setData(undefined, {
|
||||
completedManageTour: tourStatus?.completedManageTour ?? false,
|
||||
completedBoardTour: true,
|
||||
});
|
||||
completeTour({ tour: "board" });
|
||||
};
|
||||
|
||||
const steps = useMemo(() => {
|
||||
const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [
|
||||
{
|
||||
id: "board-search",
|
||||
title: t("welcome.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("welcome.description")}
|
||||
documentationHref={createDocumentationLink(
|
||||
"/docs/getting-started/after-the-installation",
|
||||
"#creating-your-first-board",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-edit-mode",
|
||||
title: t("editMode.title"),
|
||||
requiresChangeAccess: true,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("editMode.description")}
|
||||
documentationHref={createDocumentationLink(
|
||||
"/docs/getting-started/after-the-installation",
|
||||
"#arrange-and-organize-your-board",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-settings",
|
||||
title: t("settings.title"),
|
||||
requiresChangeAccess: true,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("settings.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-switcher",
|
||||
title: t("switcher.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("switcher.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-user-menu",
|
||||
title: t("userMenu.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("userMenu.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[];
|
||||
}, [hasChangeAccess, t]);
|
||||
|
||||
return (
|
||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||
{children}
|
||||
</TourShell>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import {
|
||||
IconAffiliateFilled,
|
||||
IconAppsFilled,
|
||||
IconLayoutDashboardFilled,
|
||||
IconPlus,
|
||||
IconUserFilled,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { TourShell } from "./tour-shell";
|
||||
import { TourStepContent } from "./tour-step-content";
|
||||
|
||||
interface ManageTourProviderProps extends PropsWithChildren {
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const stepRoutes: Record<string, string> = {
|
||||
"manage-welcome": "/manage",
|
||||
"manage-boards-list": "/manage/boards",
|
||||
"manage-boards-create": "/manage/boards",
|
||||
"manage-apps-list": "/manage/apps",
|
||||
"manage-apps-create": "/manage/apps",
|
||||
"manage-integrations-list": "/manage/integrations",
|
||||
"manage-integrations-create": "/manage/integrations",
|
||||
"manage-users-list": "/manage/users",
|
||||
"manage-users-create": "/manage/users",
|
||||
};
|
||||
|
||||
const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
|
||||
withReveal: false,
|
||||
popoverProps: {
|
||||
position: "bottom",
|
||||
middlewares: { shift: { padding: 16 }, flip: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
|
||||
const t = useScopedI18n("onboardingTour.manage");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||
onSuccess() {
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname);
|
||||
const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname);
|
||||
|
||||
const [tourActive, setTourActive] = useState(false);
|
||||
const [tourDismissed, setTourDismissed] = useState(false);
|
||||
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
|
||||
|
||||
useEffect(() => {
|
||||
if (tourDismissed) return;
|
||||
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
|
||||
setTourActive(true);
|
||||
}
|
||||
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManageSection) {
|
||||
setTourActive(false);
|
||||
}
|
||||
}, [isManageSection]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasCompleted = wasManageTourCompletedRef.current;
|
||||
const isCompleted = tourStatus?.completedManageTour;
|
||||
if (wasCompleted === true && isCompleted === false) {
|
||||
setTourDismissed(false);
|
||||
}
|
||||
wasManageTourCompletedRef.current = isCompleted;
|
||||
}, [tourStatus?.completedManageTour]);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
setTourDismissed(true);
|
||||
setTourActive(false);
|
||||
utils.user.getTourStatus.setData(undefined, {
|
||||
completedManageTour: true,
|
||||
completedBoardTour: tourStatus?.completedBoardTour ?? false,
|
||||
});
|
||||
completeTour({ tour: "manage" });
|
||||
router.push("/manage");
|
||||
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
|
||||
|
||||
const steps = useMemo(() => {
|
||||
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
|
||||
{
|
||||
id: "manage-welcome",
|
||||
title: t("welcome.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("welcome.description")}
|
||||
documentationHref={createDocumentationLink("/docs/category/management")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-boards-list",
|
||||
title: t("boardsList.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("boardsList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
icon={<IconLayoutDashboardFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-boards-create",
|
||||
title: t("boardsCreate.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("boardsCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-apps-list",
|
||||
title: t("appsList.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("appsList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/apps")}
|
||||
icon={<IconAppsFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-apps-create",
|
||||
title: t("appsCreate.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("appsCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/apps")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-integrations-list",
|
||||
title: t("integrationsList.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("integrationsList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/integrations")}
|
||||
icon={<IconAffiliateFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-integrations-create",
|
||||
title: t("integrationsCreate.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("integrationsCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/integrations")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-users-list",
|
||||
title: t("usersList.title"),
|
||||
adminOnly: true,
|
||||
focusRevealProps: usersStepFocusRevealProps,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("usersList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||
icon={<IconUserFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-users-create",
|
||||
title: t("usersCreate.title"),
|
||||
adminOnly: true,
|
||||
focusRevealProps: usersStepFocusRevealProps,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("usersCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
|
||||
}, [isAdmin, t]);
|
||||
|
||||
return (
|
||||
<TourShell
|
||||
steps={steps}
|
||||
started={tourActive}
|
||||
onEnd={handleEnd}
|
||||
stepRoutes={stepRoutes}
|
||||
position={{ base: "bottom", sm: "right" }}
|
||||
>
|
||||
{children}
|
||||
</TourShell>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { OnboardingTourController, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
|
||||
import type { FloatingPosition } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
|
||||
interface TourShellProps extends PropsWithChildren {
|
||||
steps: OnboardingTourStep[];
|
||||
started: boolean;
|
||||
onEnd: () => void;
|
||||
stepRoutes?: Record<string, string>;
|
||||
position?: FloatingPosition | Record<string, FloatingPosition>;
|
||||
}
|
||||
|
||||
type TourController = OnboardingTourController;
|
||||
|
||||
const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0;
|
||||
|
||||
const stepAtOffset = (controller: TourController, offset: number) => {
|
||||
const index = stepIndexFromController(controller) + offset;
|
||||
return controller.tour[index];
|
||||
};
|
||||
|
||||
const pollForElement = (targetId: string, callback: () => void) => {
|
||||
const interval = setInterval(() => {
|
||||
const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
|
||||
if (targetElement) {
|
||||
clearInterval(interval);
|
||||
callback();
|
||||
}
|
||||
}, 50);
|
||||
const timeout = setTimeout(() => clearInterval(interval), 5000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
};
|
||||
|
||||
const isTypingTarget = (target: EventTarget | null) => {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
|
||||
};
|
||||
|
||||
interface TourForwardButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
|
||||
return (
|
||||
<Button size="sm" variant="light" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
interface TourDoneButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
|
||||
return (
|
||||
<Button size="sm" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const forwardActionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const navigateAndAdvance = useCallback(
|
||||
(currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
|
||||
if (!stepRoutes || !targetStepId) {
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
|
||||
const targetRoute = stepRoutes[targetStepId];
|
||||
|
||||
if (targetRoute && targetRoute !== currentRoute) {
|
||||
router.push(targetRoute);
|
||||
pollForElement(targetStepId, advance);
|
||||
} else {
|
||||
advance();
|
||||
}
|
||||
},
|
||||
[router, stepRoutes],
|
||||
);
|
||||
|
||||
const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
|
||||
forwardActionRef.current = action;
|
||||
return action;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) {
|
||||
forwardActionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (isTypingTarget(event.target)) return;
|
||||
event.preventDefault();
|
||||
forwardActionRef.current?.();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [started]);
|
||||
|
||||
return (
|
||||
<OnboardingTour
|
||||
tour={steps}
|
||||
started={started}
|
||||
onOnboardingTourEnd={onEnd}
|
||||
withStepper={false}
|
||||
header={(controller) => (
|
||||
<>
|
||||
<Group justify="center" gap={4} py={4}>
|
||||
<Text size="sm" fw={600}>
|
||||
{stepIndexFromController(controller) + 1}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
/
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{controller.tour.length}
|
||||
</Text>
|
||||
</Group>
|
||||
{stepIndexFromController(controller) === 0 && (
|
||||
<Center py="xs">
|
||||
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
nextStepNavigation={(controller) => {
|
||||
const nextStep = stepAtOffset(controller, 1);
|
||||
const action = bindForwardAction(controller, () => {
|
||||
navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
|
||||
});
|
||||
return <TourForwardButton label={t("onboardingTour.next")} onClick={action} />;
|
||||
}}
|
||||
endStepNavigation={(controller) => {
|
||||
const action = bindForwardAction(controller, () => controller.endTour());
|
||||
return <TourDoneButton label={t("onboardingTour.done")} onClick={action} />;
|
||||
}}
|
||||
prevStepNavigation={(controller) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
const prevStep = stepAtOffset(controller, -1);
|
||||
navigateAndAdvance(controller.currentStep?.id, prevStep?.id, () => controller.prevStep());
|
||||
}}
|
||||
>
|
||||
{t("onboardingTour.prev")}
|
||||
</Button>
|
||||
)}
|
||||
skipNavigation={(controller) => (
|
||||
<Button size="sm" variant="subtle" color="gray" onClick={() => controller.skipTour()}>
|
||||
{t("onboardingTour.skip")}
|
||||
</Button>
|
||||
)}
|
||||
withPrevButton
|
||||
focusRevealProps={{
|
||||
disableTargetInteraction: true,
|
||||
popoverProps: {
|
||||
position: position ?? { base: "bottom", sm: "right" },
|
||||
width: 420,
|
||||
shadow: "xl",
|
||||
radius: "lg",
|
||||
middlewares: { shift: { padding: 16 }, flip: true },
|
||||
},
|
||||
}}
|
||||
cutoutPadding={12}
|
||||
cutoutRadius={12}
|
||||
>
|
||||
{children}
|
||||
</OnboardingTour>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface TourStepContentProps {
|
||||
description: string;
|
||||
documentationHref: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{icon && (
|
||||
<ThemeIcon variant="light" size="lg" radius="md" mt={2}>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<Text size="sm" lh={1.7} style={{ flex: 1 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Anchor href={documentationHref} target="_blank" size="xs" fw={500}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconExternalLink size={14} />
|
||||
{t("onboardingTour.learnMore")}
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
window.location.assign(logoutUrl);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
router.push("/auth/login");
|
||||
},
|
||||
});
|
||||
}, [logoutUrl, openModal, router]);
|
||||
|
||||
+17
-10
@@ -249,13 +249,20 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg" alt="PeaNUT" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">PeaNUT</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Pi-hole</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -296,15 +303,15 @@
|
||||
<br/>
|
||||
<p align="center">Radarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Readarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -345,15 +352,15 @@
|
||||
<br/>
|
||||
<p align="center">Speedtest<br/>Tracker</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Tdarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Homarr Docker label discovery example.
|
||||
# This compose file demonstrates all supported label combinations for testing.
|
||||
# Prerequisites: Homarr with Docker socket mounted.
|
||||
# In Manage → Tools → Import/Export, use the Docker Discovery panel to scan.
|
||||
|
||||
services:
|
||||
# ─── Full Homarr labels (all fields) ─────────────────────────────────────────
|
||||
sonarr:
|
||||
image: lscr.io/linuxserver/sonarr:latest
|
||||
container_name: homarr-sonarr
|
||||
ports:
|
||||
- "8989:8989"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Sonarr"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://sonarr:8989"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
|
||||
homarr.description: "TV series management"
|
||||
homarr.ping: "http://sonarr:8989/ping"
|
||||
homarr.id: "sonarr-main"
|
||||
homarr.board: "homelab"
|
||||
homarr.integration: "sonarr"
|
||||
homarr.widget: "downloads"
|
||||
|
||||
radarr:
|
||||
image: lscr.io/linuxserver/radarr:latest
|
||||
container_name: homarr-radarr
|
||||
ports:
|
||||
- "7878:7878"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Radarr"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://radarr:7878"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg"
|
||||
homarr.description: "Movie management"
|
||||
homarr.ping: "http://radarr:7878/ping"
|
||||
homarr.integration: "radarr"
|
||||
homarr.widget: "downloads"
|
||||
|
||||
# ─── Minimal Homarr labels (required fields only) ────────────────────────────
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
container_name: homarr-portainer
|
||||
ports:
|
||||
- "9000:9000"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Portainer"
|
||||
homarr.group: "Infrastructure"
|
||||
homarr.href: "http://portainer:9000"
|
||||
|
||||
# ─── Homepage labels only (migration scenario) ───────────────────────────────
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: homarr-grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homepage.name: "Grafana"
|
||||
homepage.group: "Monitoring"
|
||||
homepage.href: "http://grafana:3000"
|
||||
homepage.icon: "grafana"
|
||||
homepage.description: "Metrics and dashboards"
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: homarr-prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homepage.name: "Prometheus"
|
||||
homepage.group: "Monitoring"
|
||||
homepage.href: "http://prometheus:9090"
|
||||
homepage.icon: "prometheus"
|
||||
|
||||
# ─── Both label sets (homarr takes priority) ─────────────────────────────────
|
||||
qbittorrent:
|
||||
image: lscr.io/linuxserver/qbittorrent:latest
|
||||
container_name: homarr-qbittorrent
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "qBittorrent"
|
||||
homarr.group: "Downloads"
|
||||
homarr.href: "http://qbittorrent:8080"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg"
|
||||
homarr.description: "Torrent client"
|
||||
homarr.ping: "http://qbittorrent:8080"
|
||||
homarr.integration: "qBittorrent"
|
||||
homarr.widget: "downloads"
|
||||
homepage.name: "qBittorrent (homepage)"
|
||||
homepage.group: "Downloads (homepage)"
|
||||
homepage.href: "http://qbittorrent:8080/homepage"
|
||||
homepage.icon: "qbittorrent"
|
||||
homepage.description: "Should be ignored"
|
||||
|
||||
# ─── Hidden container (excluded from discovery) ──────────────────────────────
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
container_name: homarr-watchtower
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.hide: "true"
|
||||
homarr.name: "Watchtower"
|
||||
homarr.group: "Infrastructure"
|
||||
homarr.href: "http://watchtower:8080"
|
||||
|
||||
# ─── Targeting a specific board ──────────────────────────────────────────────
|
||||
traefik:
|
||||
image: traefik:latest
|
||||
container_name: homarr-traefik
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8081:8080"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Traefik"
|
||||
homarr.group: "Networking"
|
||||
homarr.href: "http://traefik:8080"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg"
|
||||
homarr.board: "networking"
|
||||
homarr.ping: "http://traefik:8080/ping"
|
||||
|
||||
# ─── Custom external ID ──────────────────────────────────────────────────────
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
container_name: homarr-jellyfin
|
||||
ports:
|
||||
- "8096:8096"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Jellyfin"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://jellyfin:8096"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg"
|
||||
homarr.description: "Media streaming"
|
||||
homarr.ping: "http://jellyfin:8096/health"
|
||||
homarr.id: "jellyfin-production"
|
||||
homarr.integration: "jellyfin"
|
||||
homarr.widget: "mediaServer"
|
||||
|
||||
# ─── No discovery labels (should be completely ignored) ──────────────────────
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: homarr-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.61.1",
|
||||
"version": "1.64.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -94,7 +94,7 @@
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
"tmp@<=0.2.3": ">=0.2.7",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.15"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/board-portability": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
|
||||
config: lazy(() => import("./router/config").then((mod) => mod.configRouter)),
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
getPermissionsWithParents,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import {
|
||||
exportBoardBundleAsync,
|
||||
importBoardBundleAsync,
|
||||
importHomepageServicesAsync,
|
||||
parseBundleJson,
|
||||
} from "@homarr/board-portability";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
@@ -43,6 +49,9 @@ import {
|
||||
boardChangeVisibilitySchema,
|
||||
boardCreateSchema,
|
||||
boardDuplicateSchema,
|
||||
boardExportBundleSchema,
|
||||
boardImportBundleSchema,
|
||||
boardNameSchema,
|
||||
boardRenameSchema,
|
||||
boardSaveLayoutsSchema,
|
||||
boardSavePartialSettingsSchema,
|
||||
@@ -54,6 +63,7 @@ import { zodUnionFromArray } from "@homarr/validation/enums";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
||||
@@ -1327,6 +1337,113 @@ export const boardRouter = createTRPCRouter({
|
||||
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
||||
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
|
||||
}),
|
||||
exportBundle: protectedProcedure.input(boardExportBundleSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
|
||||
const result = await exportBoardBundleAsync(ctx.db, input.id, packageJson.version);
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
filename: result.filename,
|
||||
content: result.content,
|
||||
};
|
||||
}),
|
||||
importBundle: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(boardImportBundleSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name);
|
||||
|
||||
const bundle = parseBundleJson(input.content);
|
||||
|
||||
const existingApps = await ctx.db.query.apps.findMany();
|
||||
const existingIntegrations = await ctx.db.query.integrations.findMany();
|
||||
|
||||
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||
const integrationIdsWithAccess = hasAccessForAll
|
||||
? []
|
||||
: await ctx.db
|
||||
.selectDistinct({
|
||||
id: integrationGroupPermissions.integrationId,
|
||||
})
|
||||
.from(integrationGroupPermissions)
|
||||
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
|
||||
.where(eq(groupMembers.userId, ctx.session.user.id))
|
||||
.union(
|
||||
ctx.db
|
||||
.selectDistinct({ id: integrationUserPermissions.integrationId })
|
||||
.from(integrationUserPermissions)
|
||||
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
|
||||
)
|
||||
.then((result) => result.map((row) => row.id));
|
||||
|
||||
const report = await importBoardBundleAsync(
|
||||
ctx.db,
|
||||
bundle,
|
||||
input.name,
|
||||
ctx.session.user.id,
|
||||
existingApps,
|
||||
existingIntegrations,
|
||||
integrationIdsWithAccess,
|
||||
hasAccessForAll,
|
||||
);
|
||||
|
||||
return {
|
||||
boardId: report.boardId,
|
||||
created: report.created,
|
||||
warnings: report.warnings,
|
||||
};
|
||||
}),
|
||||
importHomepageServices: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
boardName: boardNameSchema,
|
||||
createIntegrations: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.boardName);
|
||||
|
||||
try {
|
||||
const report = await importHomepageServicesAsync(
|
||||
ctx.db,
|
||||
input.content,
|
||||
input.boardName,
|
||||
ctx.session.user.id,
|
||||
input.createIntegrations,
|
||||
);
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
columns: {
|
||||
homeBoardId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.homeBoardId) {
|
||||
await ctx.db.update(users).set({ homeBoardId: report.boardId }).where(eq(users.id, ctx.session.user.id));
|
||||
}
|
||||
|
||||
return {
|
||||
boardId: report.boardId,
|
||||
created: report.created,
|
||||
warnings: report.warnings,
|
||||
unmappedWidgetTypes: report.unmappedWidgetTypes,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error instanceof Error ? error.message : "Could not import Homepage services",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
exportFullConfigAsync,
|
||||
importFullConfigAsync,
|
||||
parseAndValidateBundle,
|
||||
previewExportFullConfigAsync,
|
||||
previewImportFullConfigAsync,
|
||||
} from "@homarr/board-portability";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
export const configRouter = createTRPCRouter({
|
||||
previewExport: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
return await previewExportFullConfigAsync(ctx.db);
|
||||
}),
|
||||
|
||||
previewImport: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
|
||||
}),
|
||||
|
||||
exportFull: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
|
||||
return await exportFullConfigAsync(ctx.db, packageJson.version);
|
||||
}),
|
||||
|
||||
importFull: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -3,8 +3,13 @@ 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 { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||
import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
dockerContainersRequestHandler,
|
||||
getContainerLogsAsync,
|
||||
streamContainerLogsAsync,
|
||||
} from "@homarr/request-handler/docker";
|
||||
|
||||
import { dockerMiddleware } from "../../middlewares/docker";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
@@ -104,6 +109,110 @@ export const dockerRouter = createTRPCRouter({
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
getDiscoveredPreview: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => {
|
||||
const serverSettings = await getServerSettingsAsync(ctx.db);
|
||||
return await listDiscoveredContainersAsync({
|
||||
readHomepageLabels: serverSettings.docker.readHomepageLabels,
|
||||
});
|
||||
}),
|
||||
syncDiscovery: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.mutation(async ({ ctx }) => {
|
||||
const serverSettings = await getServerSettingsAsync(ctx.db);
|
||||
const discovered = await listDiscoveredContainersAsync({
|
||||
readHomepageLabels: serverSettings.docker.readHomepageLabels,
|
||||
});
|
||||
|
||||
return await syncDiscoveredServicesAsync(discovered, {
|
||||
targetBoardName: serverSettings.docker.targetBoardName,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
defaultItemWidth: serverSettings.docker.defaultItemWidth,
|
||||
defaultItemHeight: serverSettings.docker.defaultItemHeight,
|
||||
});
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
|
||||
|
||||
if (!logs) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Container not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
};
|
||||
}),
|
||||
subscribeLogs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
return observable<string>((emit) => {
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
let isSubscribed = true;
|
||||
|
||||
const initializeStreamAsync = async () => {
|
||||
try {
|
||||
cleanupFn = await streamContainerLogsAsync(
|
||||
input.id,
|
||||
input.tail ?? 200,
|
||||
(data) => {
|
||||
if (isSubscribed) {
|
||||
emit.next(data);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (isSubscribed) {
|
||||
emit.error(
|
||||
new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: err.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (isSubscribed) {
|
||||
emit.error(
|
||||
new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: err instanceof Error ? err.message : "Container not found",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void initializeStreamAsync();
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
cleanupFn?.();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { importFullConfigAsync, parseAndValidateBundle, previewImportFullConfigAsync } from "@homarr/board-portability";
|
||||
import { onboarding } from "@homarr/db/schema";
|
||||
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
|
||||
import {
|
||||
ensureValidTokenOrThrow,
|
||||
@@ -7,6 +10,7 @@ import {
|
||||
importInitialOldmarrInputSchema,
|
||||
} from "@homarr/old-import/import";
|
||||
|
||||
import packageJson from "../../../../../package.json";
|
||||
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
|
||||
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
|
||||
|
||||
@@ -19,12 +23,7 @@ export const importRouter = createTRPCRouter({
|
||||
}),
|
||||
validateToken: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(
|
||||
z.object({
|
||||
checksum: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ checksum: z.string(), token: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
ensureValidTokenOrThrow(input.checksum, input.token);
|
||||
@@ -40,4 +39,28 @@ export const importRouter = createTRPCRouter({
|
||||
await importInitialOldmarrAsync(ctx.db, input);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
previewInitialConfigImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
|
||||
}),
|
||||
importInitialConfigImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
await importFullConfigAsync(ctx.db, bundle, null);
|
||||
|
||||
const hasUsers = (bundle.users ?? []).length > 0;
|
||||
if (hasUsers) {
|
||||
await ctx.db.update(onboarding).set({ previousStep: "import", step: "finish" });
|
||||
} else {
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -13,6 +13,14 @@ import { dockerRouter } from "../../docker/docker-router";
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
vi.mock("@homarr/request-handler/docker", () => ({
|
||||
getContainerLogsAsync: async () => {
|
||||
await Promise.resolve();
|
||||
return "logs";
|
||||
},
|
||||
streamContainerLogsAsync: async () => {
|
||||
await Promise.resolve();
|
||||
return () => undefined;
|
||||
},
|
||||
dockerContainersRequestHandler: {
|
||||
handler: () => ({
|
||||
getCachedOrUpdatedDataAsync: async () => {
|
||||
@@ -42,6 +50,19 @@ vi.mock("@homarr/docker/env", () => ({
|
||||
ENABLE_DOCKER: true,
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/docker", () => ({
|
||||
DockerSingleton: {
|
||||
getInstances: () => [],
|
||||
},
|
||||
listDiscoveredContainersAsync: async () => [],
|
||||
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
|
||||
}));
|
||||
vi.mock("@homarr/db/queries", () => ({
|
||||
getServerSettingsAsync: async () => ({
|
||||
docker: { targetBoardName: null, readHomepageLabels: true, pruneRemoved: false },
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) =>
|
||||
({
|
||||
@@ -65,6 +86,10 @@ const validInputs: {
|
||||
restartAll: { ids: ["1"] },
|
||||
removeAll: { ids: ["1"] },
|
||||
invalidate: undefined,
|
||||
getDiscoveredPreview: undefined,
|
||||
syncDiscovery: undefined,
|
||||
logs: { id: "1", tail: 200 },
|
||||
subscribeLogs: { id: "1", tail: 200 },
|
||||
};
|
||||
|
||||
describe("All procedures should only be accessible for users with admin permission", () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
isDemoMode,
|
||||
onboardingProcedure,
|
||||
permissionRequiredProcedure,
|
||||
protectedProcedure,
|
||||
@@ -51,23 +52,45 @@ export const userRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
const groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.email, input.email),
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
|
||||
const userId = existingUser ? existingUser.id : await createUserAsync(ctx.db, input);
|
||||
|
||||
const existingGroup = await ctx.db.query.groups.findFirst({
|
||||
where: eq(groups.name, credentialsAdminGroup),
|
||||
});
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
|
||||
let groupId: string;
|
||||
if (existingGroup) {
|
||||
groupId = existingGroup.id;
|
||||
} else {
|
||||
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
|
||||
groupId = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: credentialsAdminGroup,
|
||||
ownerId: userId,
|
||||
position: maxPosition + 1,
|
||||
});
|
||||
await ctx.db.insert(groupPermissions).values({
|
||||
groupId,
|
||||
permission: "admin",
|
||||
});
|
||||
}
|
||||
|
||||
const existingMembership = await ctx.db.query.groupMembers.findFirst({
|
||||
where: and(eq(groupMembers.groupId, groupId), eq(groupMembers.userId, userId)),
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
register: publicProcedure
|
||||
@@ -278,6 +301,8 @@ export const userRouter = createTRPCRouter({
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
completedManageTour: true,
|
||||
completedBoardTour: true,
|
||||
}),
|
||||
)
|
||||
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
||||
@@ -304,6 +329,8 @@ export const userRouter = createTRPCRouter({
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
completedManageTour: true,
|
||||
completedBoardTour: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
@@ -605,6 +632,46 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
completeTour: protectedProcedure
|
||||
.input(z.object({ tour: z.enum(["manage", "board"]) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const columnByTour = {
|
||||
manage: { completedManageTour: true },
|
||||
board: { completedBoardTour: true },
|
||||
} as const;
|
||||
|
||||
await ctx.db.update(users).set(columnByTour[input.tour]).where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
resetTours: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
completedManageTour: false,
|
||||
completedBoardTour: false,
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (isDemoMode) {
|
||||
return {
|
||||
completedManageTour: true,
|
||||
completedBoardTour: true,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
completedManageTour: true,
|
||||
completedBoardTour: true,
|
||||
},
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
});
|
||||
|
||||
return {
|
||||
completedManageTour: user?.completedManageTour ?? false,
|
||||
completedBoardTour: user?.completedBoardTour ?? false,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
homarrBundleSchema,
|
||||
homarrConfigBundleSchema,
|
||||
type HomarrBundle,
|
||||
type HomarrConfigBundle,
|
||||
} from "./src/schema";
|
||||
export { parseServicesYaml } from "./src/homepage/parse-services-yaml";
|
||||
export { extractHomepageEnvVariables, replaceHomepageEnvVariables } from "./src/homepage/env-variables";
|
||||
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./src/homepage/types";
|
||||
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
CONFIG_BUNDLE_FORMAT_VERSION,
|
||||
assessBundleCompatibility,
|
||||
parseAndValidateBundle,
|
||||
parseConfigBundleJson,
|
||||
} from "./src/config-bundle-compat";
|
||||
export { USER_DIRECT_FIELDS, USER_REF_FIELDS, GROUP_REF_FIELDS } from "./src/entity-fields";
|
||||
export { resolveEntities, groupByKey, type EntityResolverDef, type ResolveResult } from "./src/resolve-entities";
|
||||
export { homarrBundleSchema, homarrConfigBundleSchema, type HomarrBundle, type HomarrConfigBundle, type ConfigBundleUser } from "./src/schema";
|
||||
export type { ConfigEntityCounts, ConfigEntityKey, ConfigExportPreview, ConfigImportPreview } from "./src/types";
|
||||
export { CONFIG_ENTITY_KEYS, emptyCounts } from "./src/types";
|
||||
export { exportBoardBundleAsync, bundleFilenameForBoard } from "./src/export";
|
||||
export { exportFullConfigAsync } from "./src/export/export-full-config";
|
||||
export { previewExportFullConfigAsync } from "./src/export/preview-export-full-config";
|
||||
export { parseBundleJson, importBoardBundleAsync, type BundleImportReport } from "./src/import";
|
||||
export { importFullConfigAsync, type ConfigImportReport } from "./src/import/import-full-config";
|
||||
export { previewImportFullConfigAsync } from "./src/import/plan-full-config-import";
|
||||
export {
|
||||
parseServicesYaml,
|
||||
importHomepageServicesAsync,
|
||||
homepageWidgetMap,
|
||||
type HomepageService,
|
||||
type HomepageImportReport,
|
||||
type ParseServicesYamlResult,
|
||||
} from "./src/homepage";
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@homarr/board-portability",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./client": "./client.ts",
|
||||
"./homepage": "./src/homepage/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"superjson": "catalog:",
|
||||
"yaml": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { HomarrBundle } from "../schema";
|
||||
import { homarrBundleSchema } from "../schema";
|
||||
import { bundleToJson, bundleFilenameForBoard } from "../export/to-json";
|
||||
import { parseBundleJson } from "../import/parse-bundle";
|
||||
|
||||
const createMinimalBundle = (overrides: Partial<HomarrBundle> = {}): HomarrBundle => ({
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
homarrVersion: "1.0.0-test",
|
||||
boards: [
|
||||
{
|
||||
name: "Test Board",
|
||||
settings: {
|
||||
primaryColor: "#FA5252",
|
||||
secondaryColor: "#868E96",
|
||||
opacity: 100,
|
||||
itemRadius: "md",
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
ref: "layout-1",
|
||||
name: "Default",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
ref: "section-1",
|
||||
kind: "category",
|
||||
name: "Apps",
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
ref: "item-1",
|
||||
kind: "app",
|
||||
options: { appId: "app-ref-1", openInNewTab: true },
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
integrationRefs: [],
|
||||
appRef: "app-ref-1",
|
||||
layouts: [
|
||||
{
|
||||
layoutRef: "layout-1",
|
||||
sectionRef: "section-1",
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 2,
|
||||
h: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
ref: "app-ref-1",
|
||||
name: "Jellyfin",
|
||||
href: "https://jellyfin.local",
|
||||
iconUrl: "https://cdn.example/jellyfin.png",
|
||||
description: "Media server",
|
||||
pingUrl: "https://jellyfin.local/health",
|
||||
},
|
||||
],
|
||||
integrations: [
|
||||
{
|
||||
ref: "int-ref-1",
|
||||
kind: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
url: "https://jellyfin.local",
|
||||
secretKinds: ["apiKey"],
|
||||
secrets: "REDACTED",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("bundle round-trip", () => {
|
||||
test("serialized bundle parses back to equal object", () => {
|
||||
const original = createMinimalBundle();
|
||||
const json = bundleToJson(original);
|
||||
const parsed = parseBundleJson(json);
|
||||
|
||||
expect(parsed.version).toBe(original.version);
|
||||
expect(parsed.homarrVersion).toBe(original.homarrVersion);
|
||||
expect(parsed.boards).toHaveLength(1);
|
||||
expect(parsed.boards[0]?.name).toBe("Test Board");
|
||||
expect(parsed.apps).toHaveLength(1);
|
||||
expect(parsed.apps[0]?.name).toBe("Jellyfin");
|
||||
expect(parsed.integrations).toHaveLength(1);
|
||||
expect(parsed.integrations[0]?.secrets).toBe("REDACTED");
|
||||
});
|
||||
|
||||
test("schema validates valid bundle", () => {
|
||||
const bundle = createMinimalBundle();
|
||||
const result = homarrBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("schema rejects missing boards", () => {
|
||||
const invalid = { ...createMinimalBundle(), boards: [] };
|
||||
const result = homarrBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("schema rejects invalid version", () => {
|
||||
const invalid = { ...createMinimalBundle(), version: "2.0" };
|
||||
const result = homarrBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("bundle preserves multiple items with layouts", () => {
|
||||
const bundle = createMinimalBundle();
|
||||
bundle.boards[0]?.items.push({
|
||||
ref: "item-2",
|
||||
kind: "clock",
|
||||
options: { is24HourFormat: true },
|
||||
advancedOptions: { title: "My Clock", customCssClasses: ["custom"], borderColor: "#000" },
|
||||
integrationRefs: [],
|
||||
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 2, y: 0, w: 2, h: 1 }],
|
||||
});
|
||||
|
||||
const json = bundleToJson(bundle);
|
||||
const parsed = parseBundleJson(json);
|
||||
|
||||
expect(parsed.boards[0]?.items).toHaveLength(2);
|
||||
expect(parsed.boards[0]?.items[1]?.kind).toBe("clock");
|
||||
expect(parsed.boards[0]?.items[1]?.advancedOptions.title).toBe("My Clock");
|
||||
});
|
||||
|
||||
test("bundle preserves section layouts", () => {
|
||||
const bundle = createMinimalBundle();
|
||||
const firstSection = bundle.boards[0]?.sections[0];
|
||||
if (!firstSection) throw new Error("missing section");
|
||||
firstSection.layouts = [
|
||||
{
|
||||
layoutRef: "layout-1",
|
||||
parentSectionRef: "section-1",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 10,
|
||||
height: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const json = bundleToJson(bundle);
|
||||
const parsed = parseBundleJson(json);
|
||||
|
||||
expect(parsed.boards[0]?.sections[0]?.layouts).toHaveLength(1);
|
||||
expect(parsed.boards[0]?.sections[0]?.layouts?.[0]?.width).toBe(10);
|
||||
});
|
||||
|
||||
test("empty apps and integrations are valid", () => {
|
||||
const bundle = createMinimalBundle({ apps: [], integrations: [] });
|
||||
if (bundle.boards[0]) {
|
||||
bundle.boards[0].items = [];
|
||||
}
|
||||
|
||||
const json = bundleToJson(bundle);
|
||||
const parsed = parseBundleJson(json);
|
||||
|
||||
expect(parsed.apps).toHaveLength(0);
|
||||
expect(parsed.integrations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleFilenameForBoard", () => {
|
||||
test("sanitizes board name for filename", () => {
|
||||
expect(bundleFilenameForBoard("My Board")).toBe("homarr-board-My-Board.json");
|
||||
expect(bundleFilenameForBoard("test_board-1")).toBe("homarr-board-test_board-1.json");
|
||||
expect(bundleFilenameForBoard("special!@#chars")).toBe("homarr-board-special---chars.json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { HomarrConfigBundle } from "../schema";
|
||||
import { homarrConfigBundleSchema } from "../schema";
|
||||
import { assessBundleCompatibility, parseConfigBundleJson } from "../config-bundle-compat";
|
||||
|
||||
const createMinimalConfigBundle = (overrides: Partial<HomarrConfigBundle> = {}): HomarrConfigBundle => ({
|
||||
version: "2.0",
|
||||
type: "full-config",
|
||||
exportedAt: new Date().toISOString(),
|
||||
homarrVersion: "1.5.0",
|
||||
encryptionKey: "a".repeat(64),
|
||||
boards: [
|
||||
{
|
||||
ref: "board-uuid-1",
|
||||
name: "Main Dashboard",
|
||||
settings: {
|
||||
primaryColor: "#FA5252",
|
||||
secondaryColor: "#868E96",
|
||||
opacity: 100,
|
||||
itemRadius: "md",
|
||||
},
|
||||
layouts: [
|
||||
{ ref: "layout-1", name: "Default", columnCount: 10, breakpoint: 0 },
|
||||
],
|
||||
sections: [
|
||||
{ ref: "section-1", kind: "category", name: "Media", yOffset: 0, xOffset: 0 },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
ref: "item-1",
|
||||
kind: "app",
|
||||
options: { appId: "app-ref-1", openInNewTab: true },
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
integrationRefs: ["int-ref-1"],
|
||||
appRef: "app-ref-1",
|
||||
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 0, y: 0, w: 2, h: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
ref: "app-ref-1",
|
||||
name: "Jellyfin",
|
||||
href: "https://jellyfin.local",
|
||||
iconUrl: "https://cdn.example/jellyfin.png",
|
||||
description: "Media server",
|
||||
pingUrl: null,
|
||||
},
|
||||
],
|
||||
integrations: [
|
||||
{
|
||||
ref: "int-ref-1",
|
||||
kind: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
url: "https://jellyfin.local",
|
||||
secrets: [{ kind: "apiKey", value: "encrypted.value" }],
|
||||
},
|
||||
],
|
||||
serverSettings: {
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
docker: { targetBoardName: null, readHomepageLabels: true },
|
||||
},
|
||||
searchEngines: [
|
||||
{
|
||||
ref: "se-1",
|
||||
iconUrl: "https://google.com/favicon.ico",
|
||||
name: "Google",
|
||||
short: "g",
|
||||
description: "Google search",
|
||||
urlTemplate: "https://google.com/search?q={query}",
|
||||
type: "generic",
|
||||
integrationRef: null,
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
ref: "group-1",
|
||||
name: "Admins",
|
||||
position: 0,
|
||||
ownerRef: "user-1",
|
||||
homeBoardRef: null,
|
||||
mobileHomeBoardRef: null,
|
||||
permissions: ["admin"],
|
||||
boardPermissions: [{ boardRef: "board-uuid-1", permission: "board-full-access" }],
|
||||
integrationPermissions: [{ integrationRef: "int-ref-1", permission: "integration-use-credentials" }],
|
||||
memberRefs: ["user-1"],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
ref: "user-1",
|
||||
name: "admin",
|
||||
email: "admin@example.com",
|
||||
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
|
||||
provider: "credentials",
|
||||
homeBoardRef: "board-uuid-1",
|
||||
mobileHomeBoardRef: null,
|
||||
defaultSearchEngineRef: "se-1",
|
||||
colorScheme: "dark",
|
||||
firstDayOfWeek: 1,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
pingIconsEnabled: false,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("v2.0 config bundle schema", () => {
|
||||
test("schema validates a valid config bundle", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("schema rejects v1.0 version for config bundle", () => {
|
||||
const invalid = { ...createMinimalConfigBundle(), version: "1.0" };
|
||||
const result = homarrConfigBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("schema rejects missing type field", () => {
|
||||
const { type: _type, ...invalid } = createMinimalConfigBundle();
|
||||
const result = homarrConfigBundleSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("schema accepts bundle with no boards", () => {
|
||||
const bundle = createMinimalConfigBundle({ boards: [] });
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("schema accepts board with optional ref field", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
delete (bundle.boards[0] as Record<string, unknown>).ref;
|
||||
const result = homarrConfigBundleSchema.safeParse(bundle);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2.0 config bundle round-trip", () => {
|
||||
test("serialized config bundle parses back correctly", () => {
|
||||
const original = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(original);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
|
||||
expect(parsed.version).toBe("2.0");
|
||||
expect(parsed.type).toBe("full-config");
|
||||
expect(parsed.homarrVersion).toBe("1.5.0");
|
||||
expect(parsed.boards).toHaveLength(1);
|
||||
expect(parsed.boards[0]?.ref).toBe("board-uuid-1");
|
||||
expect(parsed.boards[0]?.name).toBe("Main Dashboard");
|
||||
expect(parsed.apps).toHaveLength(1);
|
||||
expect(parsed.integrations).toHaveLength(1);
|
||||
expect(parsed.integrations[0]?.secrets).toHaveLength(1);
|
||||
expect(parsed.searchEngines).toHaveLength(1);
|
||||
expect(parsed.groups).toHaveLength(1);
|
||||
expect(parsed.groups[0]?.boardPermissions).toHaveLength(1);
|
||||
expect(parsed.groups[0]?.boardPermissions[0]?.boardRef).toBe("board-uuid-1");
|
||||
});
|
||||
|
||||
test("preserves encryption key", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.encryptionKey).toBe("a".repeat(64));
|
||||
});
|
||||
|
||||
test("preserves server settings", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.serverSettings).toEqual({
|
||||
board: { enableStatusByDefault: true, forceDisableStatus: false },
|
||||
docker: { targetBoardName: null, readHomepageLabels: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves users and group memberships", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundle);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
|
||||
expect(parsed.users).toHaveLength(1);
|
||||
expect(parsed.users![0]?.email).toBe("admin@example.com");
|
||||
expect(parsed.users![0]?.provider).toBe("credentials");
|
||||
expect(parsed.users![0]?.homeBoardRef).toBe("board-uuid-1");
|
||||
expect(parsed.groups[0]?.memberRefs).toEqual(["user-1"]);
|
||||
expect(parsed.groups[0]?.ownerRef).toBe("user-1");
|
||||
});
|
||||
|
||||
test("accepts bundle without users (backward compat)", () => {
|
||||
const { users: _users, ...bundleWithoutUsers } = createMinimalConfigBundle();
|
||||
const json = JSON.stringify(bundleWithoutUsers);
|
||||
const parsed = parseConfigBundleJson(json);
|
||||
expect(parsed.users).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assessBundleCompatibility", () => {
|
||||
test("marks valid bundle as compatible", () => {
|
||||
const bundle = createMinimalConfigBundle();
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("compatible");
|
||||
expect(result.bundle).not.toBeNull();
|
||||
expect(result.compatibility.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("marks compatible even with version mismatch (different Homarr version)", () => {
|
||||
const bundle = createMinimalConfigBundle({ homarrVersion: "1.4.0" });
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("compatible");
|
||||
expect(result.compatibility.issues).toHaveLength(1);
|
||||
expect(result.compatibility.issues[0]).toContain("1.4.0");
|
||||
});
|
||||
|
||||
test("rejects unsupported format version", () => {
|
||||
const bundle = { ...createMinimalConfigBundle(), version: "3.0" };
|
||||
const result = assessBundleCompatibility(bundle, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("unsupportedVersion");
|
||||
expect(result.bundle).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects non-object input", () => {
|
||||
const result = assessBundleCompatibility(null, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("invalidStructure");
|
||||
expect(result.bundle).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects malformed structure", () => {
|
||||
const result = assessBundleCompatibility({ version: "2.0", type: "full-config" }, "1.5.0");
|
||||
expect(result.compatibility.status).toBe("invalidStructure");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { HomarrConfigBundle } from "./schema";
|
||||
import { homarrConfigBundleSchema } from "./schema";
|
||||
|
||||
export const CONFIG_BUNDLE_FORMAT_VERSION = "2.0";
|
||||
|
||||
export const SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS = [CONFIG_BUNDLE_FORMAT_VERSION] as const;
|
||||
|
||||
export type BundleCompatibilityStatus = "compatible" | "unsupportedVersion" | "invalidStructure";
|
||||
|
||||
export type BundleCompatibility = {
|
||||
status: BundleCompatibilityStatus;
|
||||
bundleVersion: string | null;
|
||||
bundleHomarrVersion: string | null;
|
||||
currentHomarrVersion: string;
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
const versionSupported = (version: string) =>
|
||||
(SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS as readonly string[]).includes(version);
|
||||
|
||||
export const parseConfigBundleJson = (content: string): HomarrConfigBundle => {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
return homarrConfigBundleSchema.parse(parsed);
|
||||
};
|
||||
|
||||
export const assessBundleCompatibility = (
|
||||
raw: unknown,
|
||||
currentHomarrVersion: string,
|
||||
): { bundle: HomarrConfigBundle | null; compatibility: BundleCompatibility } => {
|
||||
const issues: string[] = [];
|
||||
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return {
|
||||
bundle: null,
|
||||
compatibility: {
|
||||
status: "invalidStructure",
|
||||
bundleVersion: null,
|
||||
bundleHomarrVersion: null,
|
||||
currentHomarrVersion,
|
||||
issues: ["File is not a valid JSON object"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const record = raw as Record<string, unknown>;
|
||||
const bundleVersion = typeof record.version === "string" ? record.version : null;
|
||||
const bundleHomarrVersion = typeof record.homarrVersion === "string" ? record.homarrVersion : null;
|
||||
|
||||
if (bundleVersion && !versionSupported(bundleVersion)) {
|
||||
issues.push(
|
||||
`Bundle format version "${bundleVersion}" is not supported. This instance expects version ${CONFIG_BUNDLE_FORMAT_VERSION}.`,
|
||||
);
|
||||
return {
|
||||
bundle: null,
|
||||
compatibility: {
|
||||
status: "unsupportedVersion",
|
||||
bundleVersion,
|
||||
bundleHomarrVersion,
|
||||
currentHomarrVersion,
|
||||
issues,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const parseResult = homarrConfigBundleSchema.safeParse(raw);
|
||||
if (!parseResult.success) {
|
||||
issues.push("Bundle structure does not match the current Homarr config schema.");
|
||||
issues.push("Ensure the exporting instance was upgraded and migrations have run before export.");
|
||||
return {
|
||||
bundle: null,
|
||||
compatibility: {
|
||||
status: "invalidStructure",
|
||||
bundleVersion,
|
||||
bundleHomarrVersion,
|
||||
currentHomarrVersion,
|
||||
issues,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const bundle = parseResult.data;
|
||||
|
||||
if (bundle.homarrVersion !== currentHomarrVersion) {
|
||||
issues.push(
|
||||
`Exported from Homarr ${bundle.homarrVersion}; this instance is ${currentHomarrVersion}. Import is allowed when the bundle format matches.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
bundle,
|
||||
compatibility: {
|
||||
status: "compatible",
|
||||
bundleVersion: bundle.version,
|
||||
bundleHomarrVersion: bundle.homarrVersion,
|
||||
currentHomarrVersion,
|
||||
issues,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const parseAndValidateBundle = (
|
||||
content: string,
|
||||
currentHomarrVersion: string,
|
||||
): HomarrConfigBundle => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
throw new Error(compatibility.issues.join(" "));
|
||||
}
|
||||
|
||||
return bundle;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Declarative field definitions for entities in the config bundle.
|
||||
*
|
||||
* To add a new user preference:
|
||||
* 1. Add the column to the DB schema (migration)
|
||||
* 2. Add the field name to USER_DIRECT_FIELDS below
|
||||
* That's it — export, import, and schema all derive from this.
|
||||
*
|
||||
* To add a new FK reference on users:
|
||||
* 1. Add the column to the DB schema (migration)
|
||||
* 2. Add an entry to USER_REF_FIELDS below (bundleKey → dbColumn → refMap name)
|
||||
*/
|
||||
|
||||
/**
|
||||
* User fields copied directly between bundle and DB (no transformation).
|
||||
* Adding a new preference here automatically includes it in export AND import.
|
||||
*/
|
||||
export const USER_DIRECT_FIELDS = [
|
||||
"name",
|
||||
"email",
|
||||
"password",
|
||||
"provider",
|
||||
"colorScheme",
|
||||
"firstDayOfWeek",
|
||||
"openSearchInNewTab",
|
||||
"ddgBangs",
|
||||
"pingIconsEnabled",
|
||||
] as const;
|
||||
|
||||
export type UserDirectField = (typeof USER_DIRECT_FIELDS)[number];
|
||||
|
||||
/**
|
||||
* User FK reference fields: bundle uses `xxxRef`, DB uses `xxxId`.
|
||||
* Key = bundle field name (with "Ref" suffix)
|
||||
* Value = which ref map to use during import (matches the entity group name)
|
||||
*/
|
||||
export const USER_REF_FIELDS = {
|
||||
homeBoardRef: "boards",
|
||||
mobileHomeBoardRef: "boards",
|
||||
defaultSearchEngineRef: "searchEngines",
|
||||
} as const;
|
||||
|
||||
export type UserRefField = keyof typeof USER_REF_FIELDS;
|
||||
|
||||
/**
|
||||
* Derives the DB column name from a bundle ref field name.
|
||||
* "homeBoardRef" → "homeBoardId"
|
||||
*/
|
||||
export const refFieldToDbColumn = (refField: string): string =>
|
||||
refField.replace(/Ref$/, "Id");
|
||||
|
||||
/**
|
||||
* Group FK reference fields.
|
||||
*/
|
||||
export const GROUP_REF_FIELDS = {
|
||||
ownerRef: "users",
|
||||
homeBoardRef: "boards",
|
||||
mobileHomeBoardRef: "boards",
|
||||
} as const;
|
||||
@@ -0,0 +1,215 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import type { Database } from "@homarr/db";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
searchEngines,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { CONFIG_BUNDLE_FORMAT_VERSION } from "../config-bundle-compat";
|
||||
import { USER_DIRECT_FIELDS, USER_REF_FIELDS } from "../entity-fields";
|
||||
import { groupByKey } from "../resolve-entities";
|
||||
import type { ConfigBundleUser, HomarrBundleApp, HomarrConfigBundle } from "../schema";
|
||||
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
|
||||
import { loadBoardGraphAsync } from "./load-board-graph";
|
||||
import { buildBoardSettings } from "./serialize-board";
|
||||
|
||||
/**
|
||||
* Exports a user row using USER_DIRECT_FIELDS and USER_REF_FIELDS.
|
||||
* Adding a new preference to USER_DIRECT_FIELDS automatically includes it here.
|
||||
*/
|
||||
const exportUserRow = (user: Record<string, unknown>): ConfigBundleUser => {
|
||||
const bundle: Record<string, unknown> = { ref: user.id };
|
||||
|
||||
for (const field of USER_DIRECT_FIELDS) {
|
||||
bundle[field] = user[field] ?? null;
|
||||
}
|
||||
|
||||
for (const refField of Object.keys(USER_REF_FIELDS)) {
|
||||
const dbCol = refField.replace(/Ref$/, "Id");
|
||||
bundle[refField] = user[dbCol] ?? null;
|
||||
}
|
||||
|
||||
return bundle as unknown as ConfigBundleUser;
|
||||
};
|
||||
|
||||
export const exportFullConfigAsync = async (
|
||||
db: Database,
|
||||
homarrVersion: string,
|
||||
): Promise<{ content: string; filename: string }> => {
|
||||
const allBoards = await db.query.boards.findMany();
|
||||
const allApps = await db.query.apps.findMany();
|
||||
const allIntegrations = await db.select().from(integrations);
|
||||
const allSecrets = await db.select().from(integrationSecrets);
|
||||
const allServerSettings = await db.select().from(serverSettings);
|
||||
const allSearchEngines = await db.select().from(searchEngines);
|
||||
const allGroups = await db.select().from(groups);
|
||||
const allGroupPermissions = await db.select().from(groupPermissions);
|
||||
const allBoardGroupPermissions = await db.select().from(boardGroupPermissions);
|
||||
const allIntegrationGroupPermissions = await db.select().from(integrationGroupPermissions);
|
||||
const allUsers = await db.select().from(users);
|
||||
const allGroupMembers = await db.select().from(groupMembers);
|
||||
|
||||
const secretsByIntegration = groupByKey(allSecrets, (s) => s.integrationId);
|
||||
|
||||
const bundleIntegrations = allIntegrations.map((integration) => ({
|
||||
ref: integration.id,
|
||||
kind: integration.kind,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
secrets: (secretsByIntegration.get(integration.id) ?? []).map((s) => ({
|
||||
kind: s.kind as IntegrationSecretKind,
|
||||
value: s.value,
|
||||
})),
|
||||
}));
|
||||
|
||||
const bundleApps: HomarrBundleApp[] = allApps.map((app) => ({
|
||||
ref: app.id,
|
||||
name: app.name,
|
||||
href: app.href,
|
||||
iconUrl: app.iconUrl,
|
||||
description: app.description,
|
||||
pingUrl: app.pingUrl,
|
||||
}));
|
||||
|
||||
const appIdSet = new Set(allApps.map((a) => a.id));
|
||||
const appIdToRef = new Map(allApps.map((app) => [app.id, app.id] as const));
|
||||
|
||||
const bundleBoards = [];
|
||||
for (const boardRow of allBoards) {
|
||||
const boardGraph = await loadBoardGraphAsync(db, boardRow.id);
|
||||
if (!boardGraph) continue;
|
||||
|
||||
const bundleItems = boardGraph.items.map((item) => {
|
||||
const options = parseStoredValue<Record<string, unknown>>(item.options, {});
|
||||
const advancedOptions = itemAdvancedOptionsSchema.parse(
|
||||
parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})),
|
||||
);
|
||||
const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record<string, unknown>;
|
||||
const appRef = typeof options.appId === "string" && appIdSet.has(options.appId) ? options.appId : undefined;
|
||||
|
||||
return {
|
||||
ref: item.id,
|
||||
kind: item.kind,
|
||||
options: portableOptions,
|
||||
advancedOptions,
|
||||
integrationRefs: item.integrations.map(({ integrationId }) => integrationId),
|
||||
appRef,
|
||||
layouts: item.layouts.map((layout) => ({
|
||||
layoutRef: layout.layoutId,
|
||||
sectionRef: layout.sectionId,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
bundleBoards.push({
|
||||
ref: boardRow.id,
|
||||
name: boardGraph.name,
|
||||
settings: buildBoardSettings(boardGraph),
|
||||
layouts: boardGraph.layouts.map((layout) => ({
|
||||
ref: layout.id,
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
breakpoint: layout.breakpoint,
|
||||
})),
|
||||
sections: boardGraph.sections.map((section) => ({
|
||||
ref: section.id,
|
||||
kind: section.kind,
|
||||
name: section.name,
|
||||
yOffset: section.yOffset,
|
||||
xOffset: section.xOffset,
|
||||
options: section.options ? (parseStoredValue(section.options, {}) as Record<string, unknown>) : undefined,
|
||||
layouts:
|
||||
section.layouts.length > 0
|
||||
? section.layouts.map((layout) => ({
|
||||
layoutRef: layout.layoutId,
|
||||
parentSectionRef: layout.parentSectionId ?? section.id,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
}))
|
||||
: undefined,
|
||||
})),
|
||||
items: bundleItems,
|
||||
});
|
||||
}
|
||||
|
||||
const settingsRecord: Record<string, unknown> = {};
|
||||
for (const setting of allServerSettings) {
|
||||
settingsRecord[setting.settingKey] = SuperJSON.parse(setting.value);
|
||||
}
|
||||
|
||||
const bundleSearchEngines = allSearchEngines.map((se) => ({
|
||||
ref: se.id,
|
||||
iconUrl: se.iconUrl,
|
||||
name: se.name,
|
||||
short: se.short,
|
||||
description: se.description,
|
||||
urlTemplate: se.urlTemplate,
|
||||
type: se.type,
|
||||
integrationRef: se.integrationId,
|
||||
}));
|
||||
|
||||
const permissionsByGroup = groupByKey(allGroupPermissions, (gp) => gp.groupId);
|
||||
const boardPermsByGroup = groupByKey(allBoardGroupPermissions, (bp) => bp.groupId);
|
||||
const integrationPermsByGroup = groupByKey(allIntegrationGroupPermissions, (ip) => ip.groupId);
|
||||
const membersByGroup = groupByKey(allGroupMembers, (gm) => gm.groupId);
|
||||
|
||||
const bundleGroups = allGroups.map((group) => ({
|
||||
ref: group.id,
|
||||
name: group.name,
|
||||
position: group.position,
|
||||
ownerRef: group.ownerId ?? null,
|
||||
homeBoardRef: group.homeBoardId ?? null,
|
||||
mobileHomeBoardRef: group.mobileHomeBoardId ?? null,
|
||||
permissions: (permissionsByGroup.get(group.id) ?? []).map((gp) => gp.permission),
|
||||
boardPermissions: (boardPermsByGroup.get(group.id) ?? []).map((bp) => ({
|
||||
boardRef: bp.boardId,
|
||||
permission: bp.permission,
|
||||
})),
|
||||
integrationPermissions: (integrationPermsByGroup.get(group.id) ?? []).map((ip) => ({
|
||||
integrationRef: ip.integrationId,
|
||||
permission: ip.permission,
|
||||
})),
|
||||
memberRefs: (membersByGroup.get(group.id) ?? []).map((gm) => gm.userId),
|
||||
}));
|
||||
|
||||
const bundleUsers = allUsers.map((user) => exportUserRow(user as unknown as Record<string, unknown>));
|
||||
|
||||
const bundle: HomarrConfigBundle = {
|
||||
version: CONFIG_BUNDLE_FORMAT_VERSION,
|
||||
type: "full-config",
|
||||
exportedAt: new Date().toISOString(),
|
||||
homarrVersion,
|
||||
encryptionKey: env.SECRET_ENCRYPTION_KEY ?? "0".repeat(64),
|
||||
boards: bundleBoards,
|
||||
apps: bundleApps,
|
||||
integrations: bundleIntegrations,
|
||||
serverSettings: settingsRecord,
|
||||
searchEngines: bundleSearchEngines,
|
||||
groups: bundleGroups,
|
||||
users: bundleUsers,
|
||||
};
|
||||
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
return {
|
||||
content: JSON.stringify(bundle, null, 2),
|
||||
filename: `homarr-config-${now}.json`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
|
||||
import { loadBoardGraphAsync } from "./load-board-graph";
|
||||
import { serializeBoardToBundleAsync } from "./serialize-board";
|
||||
import { bundleFilenameForBoard, bundleToJson } from "./to-json";
|
||||
|
||||
export const exportBoardBundleAsync = async (db: Database, boardId: string, homarrVersion: string) => {
|
||||
const board = await loadBoardGraphAsync(db, boardId);
|
||||
if (!board) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bundle = await serializeBoardToBundleAsync(db, board, homarrVersion);
|
||||
return {
|
||||
bundle,
|
||||
filename: bundleFilenameForBoard(board.name),
|
||||
content: bundleToJson(bundle),
|
||||
};
|
||||
};
|
||||
|
||||
export { loadBoardGraphAsync } from "./load-board-graph";
|
||||
export { serializeBoardToBundleAsync } from "./serialize-board";
|
||||
export { bundleToJson, bundleFilenameForBoard } from "./to-json";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { eq } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
|
||||
export const loadBoardGraphAsync = async (db: Database, boardId: string) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, boardId),
|
||||
with: {
|
||||
layouts: true,
|
||||
sections: {
|
||||
with: {
|
||||
layouts: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
with: {
|
||||
layouts: true,
|
||||
integrations: {
|
||||
with: {
|
||||
integration: {
|
||||
with: {
|
||||
secrets: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return board;
|
||||
};
|
||||
|
||||
export type LoadedBoardGraph = NonNullable<Awaited<ReturnType<typeof loadBoardGraphAsync>>>;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
import {
|
||||
groups,
|
||||
integrationSecrets,
|
||||
integrations,
|
||||
items,
|
||||
layouts,
|
||||
searchEngines,
|
||||
sections,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
|
||||
import type { ConfigExportPreview } from "../types";
|
||||
|
||||
export const previewExportFullConfigAsync = async (db: Database): Promise<ConfigExportPreview> => {
|
||||
const allBoards = await db.query.boards.findMany();
|
||||
const allApps = await db.query.apps.findMany();
|
||||
const allIntegrations = await db.select().from(integrations);
|
||||
const allSecrets = await db.select().from(integrationSecrets);
|
||||
const allItems = await db.select().from(items);
|
||||
const allSections = await db.select().from(sections);
|
||||
const allLayouts = await db.select().from(layouts);
|
||||
const allSearchEngines = await db.select().from(searchEngines);
|
||||
const allGroups = await db.select().from(groups);
|
||||
const allUsers = await db.select().from(users);
|
||||
const allServerSettings = await db.select().from(serverSettings);
|
||||
|
||||
return {
|
||||
boards: allBoards.length,
|
||||
apps: allApps.length,
|
||||
integrations: allIntegrations.length,
|
||||
secrets: allSecrets.length,
|
||||
widgets: allItems.length,
|
||||
sections: allSections.length,
|
||||
layouts: allLayouts.length,
|
||||
searchEngines: allSearchEngines.length,
|
||||
groups: allGroups.length,
|
||||
users: allUsers.length,
|
||||
serverSettings: allServerSettings.length,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { inArray } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { HomarrBundle, HomarrBundleApp, HomarrBundleIntegration } from "../schema";
|
||||
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
|
||||
import type { LoadedBoardGraph } from "./load-board-graph";
|
||||
|
||||
export const buildBoardSettings = (board: LoadedBoardGraph) => ({
|
||||
pageTitle: board.pageTitle,
|
||||
metaTitle: board.metaTitle,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
faviconImageUrl: board.faviconImageUrl,
|
||||
backgroundImageUrl: board.backgroundImageUrl,
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
backgroundImageRepeat: board.backgroundImageRepeat,
|
||||
backgroundImageSize: board.backgroundImageSize,
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
opacity: board.opacity,
|
||||
customCss: board.customCss,
|
||||
iconColor: board.iconColor,
|
||||
itemRadius: board.itemRadius,
|
||||
disableStatus: board.disableStatus,
|
||||
isPublic: board.isPublic,
|
||||
});
|
||||
|
||||
const walkValues = (value: unknown, onString: (candidate: string) => void) => {
|
||||
if (typeof value === "string") {
|
||||
onString(value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => walkValues(entry, onString));
|
||||
return;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
Object.values(value).forEach((entry) => walkValues(entry, onString));
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeBoardToBundleAsync = async (
|
||||
db: Database,
|
||||
board: LoadedBoardGraph,
|
||||
homarrVersion: string,
|
||||
): Promise<HomarrBundle> => {
|
||||
const appIdCandidates = new Set<string>();
|
||||
const integrationRecords = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
kind: LoadedBoardGraph["items"][number]["integrations"][number]["integration"]["kind"];
|
||||
name: string;
|
||||
url: string;
|
||||
secretKinds: IntegrationSecretKind[];
|
||||
}
|
||||
>();
|
||||
|
||||
board.items.forEach((item) => {
|
||||
const options = parseStoredValue<Record<string, unknown>>(item.options, {});
|
||||
walkValues(options, (candidate) => appIdCandidates.add(candidate));
|
||||
|
||||
item.integrations.forEach(({ integration }) => {
|
||||
integrationRecords.set(integration.id, {
|
||||
id: integration.id,
|
||||
kind: integration.kind,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
secretKinds: integration.secrets.map((secret) => secret.kind),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const dbApps =
|
||||
appIdCandidates.size > 0
|
||||
? await db.query.apps.findMany({
|
||||
where: inArray(apps.id, [...appIdCandidates]),
|
||||
})
|
||||
: [];
|
||||
|
||||
const appIdToRef = new Map(dbApps.map((app) => [app.id, app.id] as const));
|
||||
|
||||
const bundleApps: HomarrBundleApp[] = dbApps.map((app) => ({
|
||||
ref: app.id,
|
||||
name: app.name,
|
||||
href: app.href,
|
||||
iconUrl: app.iconUrl,
|
||||
description: app.description,
|
||||
pingUrl: app.pingUrl,
|
||||
}));
|
||||
|
||||
const bundleIntegrations: HomarrBundleIntegration[] = [...integrationRecords.values()].map((integration) => ({
|
||||
ref: integration.id,
|
||||
kind: integration.kind,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
secretKinds: integration.secretKinds,
|
||||
secrets: "REDACTED" as const,
|
||||
}));
|
||||
|
||||
const bundleItems = board.items.map((item) => {
|
||||
const options = parseStoredValue<Record<string, unknown>>(item.options, {});
|
||||
const advancedOptions = itemAdvancedOptionsSchema.parse(
|
||||
parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})),
|
||||
);
|
||||
const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record<string, unknown>;
|
||||
const appRef = typeof options.appId === "string" ? appIdToRef.get(options.appId) : undefined;
|
||||
|
||||
return {
|
||||
ref: item.id,
|
||||
kind: item.kind,
|
||||
options: portableOptions,
|
||||
advancedOptions,
|
||||
integrationRefs: item.integrations.map(({ integrationId }) => integrationId),
|
||||
appRef,
|
||||
layouts: item.layouts.map((layout) => ({
|
||||
layoutRef: layout.layoutId,
|
||||
sectionRef: layout.sectionId,
|
||||
x: layout.xOffset,
|
||||
y: layout.yOffset,
|
||||
w: layout.width,
|
||||
h: layout.height,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
homarrVersion,
|
||||
boards: [
|
||||
{
|
||||
name: board.name,
|
||||
settings: buildBoardSettings(board),
|
||||
layouts: board.layouts.map((layout) => ({
|
||||
ref: layout.id,
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
breakpoint: layout.breakpoint,
|
||||
})),
|
||||
sections: board.sections.map((section) => ({
|
||||
ref: section.id,
|
||||
kind: section.kind,
|
||||
name: section.name,
|
||||
yOffset: section.yOffset,
|
||||
xOffset: section.xOffset,
|
||||
options: section.options ? (parseStoredValue(section.options, {}) as Record<string, unknown>) : undefined,
|
||||
layouts:
|
||||
section.layouts.length > 0
|
||||
? section.layouts.map((layout) => ({
|
||||
layoutRef: layout.layoutId,
|
||||
parentSectionRef: layout.parentSectionId ?? section.id,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
}))
|
||||
: undefined,
|
||||
})),
|
||||
items: bundleItems,
|
||||
},
|
||||
],
|
||||
apps: bundleApps,
|
||||
integrations: bundleIntegrations,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { HomarrBundle } from "../schema";
|
||||
|
||||
export const bundleToJson = (bundle: HomarrBundle) => {
|
||||
return JSON.stringify(bundle, null, 2);
|
||||
};
|
||||
|
||||
export const bundleFilenameForBoard = (boardName: string) => {
|
||||
const sanitized = boardName.replace(/[^A-Za-z0-9-_]/g, "-");
|
||||
return `homarr-board-${sanitized}.json`;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
const ENV_VAR_PATTERN = /\{\{(HOMEPAGE_VAR_[A-Z0-9_]+)\}\}/g;
|
||||
|
||||
export const extractHomepageEnvVariables = (content: string): string[] => {
|
||||
const matches = new Set<string>();
|
||||
for (const match of content.matchAll(ENV_VAR_PATTERN)) {
|
||||
matches.add(match[1] ?? "");
|
||||
}
|
||||
return [...matches].sort();
|
||||
};
|
||||
|
||||
export const replaceHomepageEnvVariables = (
|
||||
content: string,
|
||||
values: Record<string, string>,
|
||||
): string =>
|
||||
content.replaceAll(ENV_VAR_PATTERN, (full, varName: string) => values[varName] ?? full);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { handleTransactionsAsync } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
|
||||
import { parseServicesYaml } from "./parse-services-yaml";
|
||||
import { prepareHomepageImport } from "./prepare-homepage-import";
|
||||
|
||||
export type HomepageImportReport = {
|
||||
boardId: string;
|
||||
created: {
|
||||
apps: number;
|
||||
integrations: number;
|
||||
items: number;
|
||||
sections: number;
|
||||
};
|
||||
warnings: string[];
|
||||
unmappedWidgetTypes: string[];
|
||||
};
|
||||
|
||||
export const importHomepageServicesAsync = async (
|
||||
db: Database,
|
||||
content: string,
|
||||
boardName: string,
|
||||
creatorId: string,
|
||||
createIntegrations: boolean,
|
||||
): Promise<HomepageImportReport> => {
|
||||
const parsed = parseServicesYaml(content);
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
|
||||
const prepared = prepareHomepageImport(parsed.services, boardName, creatorId, createIntegrations);
|
||||
|
||||
const insertCollection = createDbInsertCollectionForTransaction([
|
||||
"apps",
|
||||
"integrations",
|
||||
"integrationSecrets",
|
||||
"boards",
|
||||
"layouts",
|
||||
"sections",
|
||||
"items",
|
||||
"itemLayouts",
|
||||
"integrationItems",
|
||||
]);
|
||||
|
||||
insertCollection.apps.push(...prepared.apps);
|
||||
insertCollection.integrations.push(...prepared.integrations);
|
||||
insertCollection.integrationSecrets.push(...prepared.integrationSecrets);
|
||||
insertCollection.boards.push(prepared.board);
|
||||
insertCollection.layouts.push(...prepared.layouts);
|
||||
insertCollection.sections.push(...prepared.sections);
|
||||
insertCollection.items.push(...prepared.items);
|
||||
insertCollection.itemLayouts.push(...prepared.itemLayouts);
|
||||
insertCollection.integrationItems.push(...prepared.integrationItems);
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(innerDb) {
|
||||
await insertCollection.insertAllAsync(innerDb);
|
||||
},
|
||||
handleSync(innerDb) {
|
||||
insertCollection.insertAll(innerDb);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
boardId: prepared.board.id,
|
||||
created: {
|
||||
apps: prepared.apps.length,
|
||||
integrations: prepared.integrations.length,
|
||||
items: prepared.items.length,
|
||||
sections: prepared.sections.length,
|
||||
},
|
||||
warnings: prepared.warnings,
|
||||
unmappedWidgetTypes: prepared.unmappedWidgetTypes,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { parseServicesYaml } from "./parse-services-yaml";
|
||||
export { homepageWidgetMap, type HomepageWidgetMapping } from "@homarr/definitions";
|
||||
export { prepareHomepageImport, resolveHomepageIconUrl, type PreparedHomepageImport } from "./prepare-homepage-import";
|
||||
export { importHomepageServicesAsync, type HomepageImportReport } from "./import";
|
||||
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseServicesYaml } from "./parse-services-yaml";
|
||||
|
||||
const sampleYaml = `
|
||||
- Media:
|
||||
- Sonarr:
|
||||
icon: sonarr.png
|
||||
href: http://sonarr.host/
|
||||
description: Series management
|
||||
ping: sonarr.host
|
||||
widget:
|
||||
type: sonarr
|
||||
url: http://sonarr.host
|
||||
key: apikeyhere
|
||||
- Radarr:
|
||||
icon: radarr.png
|
||||
href: http://radarr.host/
|
||||
widgets:
|
||||
- type: radarr
|
||||
url: http://radarr.host
|
||||
key: radarrkey
|
||||
- Downloads:
|
||||
- qBittorrent:
|
||||
icon: qbittorrent.png
|
||||
href: http://qbit.host/
|
||||
widget:
|
||||
type: qbittorrent
|
||||
url: http://qbit.host
|
||||
username: admin
|
||||
password: pass123
|
||||
`;
|
||||
|
||||
describe("parseServicesYaml", () => {
|
||||
test("parses groups, services, singular widget, and plural widgets", () => {
|
||||
const result = parseServicesYaml(sampleYaml);
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.services).toHaveLength(3);
|
||||
|
||||
const sonarr = result.services.find((service) => service.name === "Sonarr");
|
||||
expect(sonarr).toMatchObject({
|
||||
group: "Media",
|
||||
href: "http://sonarr.host/",
|
||||
ping: "sonarr.host",
|
||||
});
|
||||
expect(sonarr?.widgets).toHaveLength(1);
|
||||
expect(sonarr?.widgets[0]).toMatchObject({ type: "sonarr", key: "apikeyhere" });
|
||||
|
||||
const radarr = result.services.find((service) => service.name === "Radarr");
|
||||
expect(radarr?.widgets).toHaveLength(1);
|
||||
expect(radarr?.widgets[0]?.type).toBe("radarr");
|
||||
|
||||
const qbit = result.services.find((service) => service.name === "qBittorrent");
|
||||
expect(qbit?.group).toBe("Downloads");
|
||||
expect(qbit?.widgets[0]).toMatchObject({ type: "qbittorrent", fields: { username: "admin" } });
|
||||
});
|
||||
|
||||
test("flattens nested groups with separator", () => {
|
||||
const yaml = `
|
||||
- Parent:
|
||||
- Child:
|
||||
- Service:
|
||||
href: http://service.local
|
||||
`;
|
||||
const result = parseServicesYaml(yaml);
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.services[0]?.group).toBe("Parent / Child");
|
||||
expect(result.services[0]?.name).toBe("Service");
|
||||
});
|
||||
|
||||
test("parses services without widgets", () => {
|
||||
const yaml = `
|
||||
- Tools:
|
||||
- Homepage:
|
||||
href: http://homepage.local
|
||||
`;
|
||||
const result = parseServicesYaml(yaml);
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.services[0]?.widgets).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty services for empty content", () => {
|
||||
expect(parseServicesYaml(" ")).toEqual({ success: true, services: [] });
|
||||
});
|
||||
|
||||
test("handles malformed yaml gracefully", () => {
|
||||
const result = parseServicesYaml("invalid: yaml: [");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.error).toBe("Invalid YAML");
|
||||
});
|
||||
|
||||
test("rejects non-array root documents", () => {
|
||||
const result = parseServicesYaml("name: not-an-array");
|
||||
expect(result).toEqual({ success: false, error: "Homepage services.yaml must be a YAML array" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
|
||||
|
||||
const extractWidgets = (config: Record<string, unknown>): HomepageWidget[] => {
|
||||
const singular = config.widget;
|
||||
const plural = config.widgets;
|
||||
|
||||
if (Array.isArray(plural)) {
|
||||
return plural.filter(isWidgetConfig).map(normalizeWidget);
|
||||
}
|
||||
|
||||
if (isWidgetConfig(singular)) {
|
||||
return [normalizeWidget(singular)];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const isWidgetConfig = (value: unknown): value is Record<string, unknown> & { type: string } => {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
"type" in value &&
|
||||
typeof value.type === "string"
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeWidget = (widget: Record<string, unknown>): HomepageWidget => {
|
||||
const { type, url, key, ...rest } = widget;
|
||||
return {
|
||||
type: String(type),
|
||||
...(typeof url === "string" ? { url } : {}),
|
||||
...(typeof key === "string" ? { key } : {}),
|
||||
fields: rest,
|
||||
};
|
||||
};
|
||||
|
||||
const isServiceConfig = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const parseService = (group: string, name: string, config: Record<string, unknown>): HomepageService => {
|
||||
const icon = typeof config.icon === "string" ? config.icon : undefined;
|
||||
const href = typeof config.href === "string" ? config.href : undefined;
|
||||
const description = typeof config.description === "string" ? config.description : undefined;
|
||||
const ping = typeof config.ping === "string" ? config.ping : undefined;
|
||||
const siteMonitor = typeof config.siteMonitor === "string" ? config.siteMonitor : undefined;
|
||||
|
||||
return {
|
||||
group,
|
||||
name,
|
||||
icon,
|
||||
href,
|
||||
description,
|
||||
ping,
|
||||
siteMonitor,
|
||||
widgets: extractWidgets(config),
|
||||
};
|
||||
};
|
||||
|
||||
const collectServices = (entries: unknown[], groupPath: string): HomepageService[] => {
|
||||
const services: HomepageService[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isServiceConfig(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keys = Object.keys(entry);
|
||||
if (keys.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = keys[0] ?? "";
|
||||
const value = entry[name];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const nestedGroup = groupPath ? `${groupPath} / ${name}` : name;
|
||||
services.push(...collectServices(value, nestedGroup));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isServiceConfig(value)) {
|
||||
services.push(parseService(groupPath.length > 0 ? groupPath : "Services", name, value));
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
};
|
||||
|
||||
export const parseServicesYaml = (content: string): ParseServicesYamlResult => {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { success: true, services: [] };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(trimmed);
|
||||
} catch {
|
||||
return { success: false, error: "Invalid YAML" };
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { success: false, error: "Homepage services.yaml must be a YAML array" };
|
||||
}
|
||||
|
||||
return { success: true, services: collectServices(parsed, "") };
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type {
|
||||
apps,
|
||||
boards,
|
||||
integrationItems,
|
||||
integrationSecrets,
|
||||
integrations,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
sections,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { emptySuperJSON, homepageWidgetMap, integrationDefs } from "@homarr/definitions";
|
||||
|
||||
import { stringifyForDb } from "../utils";
|
||||
import type { HomepageService, HomepageWidget } from "./types";
|
||||
|
||||
const DASHBOARD_ICON_CDN = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg";
|
||||
const ENV_VAR_PATTERN = /^\$\{[^}]+\}$/;
|
||||
const COLUMN_COUNT = 10;
|
||||
const WIDGET_WIDTH = 2;
|
||||
|
||||
export type PreparedHomepageImport = {
|
||||
apps: InferInsertModel<typeof apps>[];
|
||||
integrations: InferInsertModel<typeof integrations>[];
|
||||
integrationSecrets: InferInsertModel<typeof integrationSecrets>[];
|
||||
board: InferInsertModel<typeof boards>;
|
||||
layouts: InferInsertModel<typeof layouts>[];
|
||||
sections: InferInsertModel<typeof sections>[];
|
||||
items: InferInsertModel<typeof items>[];
|
||||
itemLayouts: InferInsertModel<typeof itemLayouts>[];
|
||||
integrationItems: InferInsertModel<typeof integrationItems>[];
|
||||
warnings: string[];
|
||||
unmappedWidgetTypes: string[];
|
||||
};
|
||||
|
||||
type GridPosition = { x: number; y: number };
|
||||
|
||||
const advanceGrid = (grid: GridPosition, width: number): GridPosition => {
|
||||
const nextX = grid.x + width;
|
||||
if (nextX >= COLUMN_COUNT) {
|
||||
return { x: 0, y: grid.y + 1 };
|
||||
}
|
||||
return { x: nextX, y: grid.y };
|
||||
};
|
||||
|
||||
const isEnvVarReference = (value: string) => ENV_VAR_PATTERN.test(value.trim());
|
||||
|
||||
export const resolveHomepageIconUrl = (icon: string | undefined, serviceName: string): string => {
|
||||
if (!icon || icon.trim().length === 0) {
|
||||
const fallback = serviceName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "");
|
||||
return `${DASHBOARD_ICON_CDN}/${fallback || "generic"}.svg`;
|
||||
}
|
||||
|
||||
if (icon.startsWith("http://") || icon.startsWith("https://")) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
const baseName = icon.replace(/\.(png|webp|svg|jpg|jpeg)$/i, "");
|
||||
return `${DASHBOARD_ICON_CDN}/${baseName}.svg`;
|
||||
};
|
||||
|
||||
const resolvePingUrl = (service: HomepageService): string | null => {
|
||||
const raw = service.ping ?? service.siteMonitor;
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return `http://${raw}`;
|
||||
};
|
||||
|
||||
const extractSecretValue = (widget: HomepageWidget, homepageField: string): string | undefined => {
|
||||
const value = widget.fields[homepageField] ?? (homepageField === "key" ? widget.key : undefined);
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
};
|
||||
|
||||
const descriptionDisplayModes: Record<string, string> = {
|
||||
present: "tooltip",
|
||||
absent: "hidden",
|
||||
};
|
||||
|
||||
type WidgetProcessingContext = {
|
||||
service: HomepageService;
|
||||
appId: string;
|
||||
sectionId: string;
|
||||
boardId: string;
|
||||
layoutId: string;
|
||||
gridState: Map<string, GridPosition>;
|
||||
integrationRows: InferInsertModel<typeof integrations>[];
|
||||
integrationSecretRows: InferInsertModel<typeof integrationSecrets>[];
|
||||
itemRows: InferInsertModel<typeof items>[];
|
||||
itemLayoutRows: InferInsertModel<typeof itemLayouts>[];
|
||||
integrationItemRows: InferInsertModel<typeof integrationItems>[];
|
||||
warnings: string[];
|
||||
unmappedWidgetTypesSet: Set<string>;
|
||||
};
|
||||
|
||||
const processServiceWidgets = (ctx: WidgetProcessingContext) => {
|
||||
for (const widget of ctx.service.widgets) {
|
||||
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
|
||||
if (!mapping) {
|
||||
ctx.unmappedWidgetTypesSet.add(widget.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
const integrationId = createId();
|
||||
const integrationUrl =
|
||||
(typeof widget.url === "string" && widget.url.length > 0 ? widget.url : ctx.service.href) ?? "";
|
||||
|
||||
if (integrationUrl.length === 0) {
|
||||
ctx.warnings.push(`Skipped integration for "${ctx.service.name}" (${widget.type}): missing URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.integrationRows.push({
|
||||
id: integrationId,
|
||||
kind: mapping.integrationKind,
|
||||
name: ctx.service.name,
|
||||
url: integrationUrl,
|
||||
appId: ctx.appId,
|
||||
});
|
||||
|
||||
processSecrets(ctx, widget, mapping, integrationId);
|
||||
validateSecrets(ctx, mapping, integrationId);
|
||||
placeWidgetItem(ctx, mapping.widgetKind, integrationId);
|
||||
}
|
||||
};
|
||||
|
||||
const processSecrets = (
|
||||
ctx: WidgetProcessingContext,
|
||||
widget: HomepageWidget,
|
||||
mapping: { secretFieldMap: Record<string, IntegrationSecretKind>; integrationKind: string },
|
||||
integrationId: string,
|
||||
) => {
|
||||
for (const [homepageField, secretKind] of Object.entries(mapping.secretFieldMap)) {
|
||||
const rawValue = extractSecretValue(widget, homepageField);
|
||||
if (!rawValue) continue;
|
||||
|
||||
if (isEnvVarReference(rawValue)) {
|
||||
ctx.warnings.push(
|
||||
`Unresolved secret for "${ctx.service.name}" (${widget.type}): ${homepageField} references an environment variable`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.integrationSecretRows.push({
|
||||
integrationId,
|
||||
kind: secretKind as IntegrationSecretKind,
|
||||
value: encryptSecret(rawValue),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateSecrets = (
|
||||
ctx: WidgetProcessingContext,
|
||||
mapping: { integrationKind: string },
|
||||
integrationId: string,
|
||||
) => {
|
||||
const requiredSecretSets = integrationDefs[mapping.integrationKind as keyof typeof integrationDefs].secretKinds;
|
||||
const providedKinds = new Set(
|
||||
ctx.integrationSecretRows.filter((s) => s.integrationId === integrationId).map((s) => s.kind),
|
||||
);
|
||||
const hasRequiredSecrets = requiredSecretSets.some(
|
||||
(secretSet) => secretSet.length === 0 || secretSet.every((kind) => providedKinds.has(kind)),
|
||||
);
|
||||
|
||||
if (!hasRequiredSecrets && requiredSecretSets.every((set) => set.length > 0)) {
|
||||
ctx.warnings.push(`Integration "${ctx.service.name}" (${mapping.integrationKind}) may be missing required credentials`);
|
||||
}
|
||||
};
|
||||
|
||||
const placeWidgetItem = (ctx: WidgetProcessingContext, widgetKind: string, integrationId: string) => {
|
||||
const widgetItemId = createId();
|
||||
const grid = ctx.gridState.get(ctx.sectionId) ?? { x: 0, y: 0 };
|
||||
|
||||
ctx.itemRows.push({
|
||||
id: widgetItemId,
|
||||
boardId: ctx.boardId,
|
||||
kind: widgetKind as never,
|
||||
options: emptySuperJSON,
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
ctx.itemLayoutRows.push({
|
||||
itemId: widgetItemId,
|
||||
sectionId: ctx.sectionId,
|
||||
layoutId: ctx.layoutId,
|
||||
width: WIDGET_WIDTH,
|
||||
height: 1,
|
||||
xOffset: grid.x,
|
||||
yOffset: grid.y,
|
||||
});
|
||||
|
||||
ctx.gridState.set(ctx.sectionId, advanceGrid(grid, WIDGET_WIDTH));
|
||||
|
||||
ctx.integrationItemRows.push({
|
||||
itemId: widgetItemId,
|
||||
integrationId,
|
||||
});
|
||||
};
|
||||
|
||||
export const prepareHomepageImport = (
|
||||
services: HomepageService[],
|
||||
boardName: string,
|
||||
creatorId: string,
|
||||
createIntegrations: boolean,
|
||||
): PreparedHomepageImport => {
|
||||
const warnings: string[] = [];
|
||||
const unmappedWidgetTypesSet = new Set<string>();
|
||||
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
|
||||
const board: InferInsertModel<typeof boards> = {
|
||||
id: boardId,
|
||||
name: boardName,
|
||||
creatorId,
|
||||
isPublic: false,
|
||||
};
|
||||
|
||||
const layoutRows: InferInsertModel<typeof layouts>[] = [
|
||||
{ id: layoutId, boardId, name: "Base", columnCount: COLUMN_COUNT, breakpoint: 0 },
|
||||
];
|
||||
|
||||
const groupNames = [...new Set(services.map((service) => service.group || "Services"))];
|
||||
const sectionByGroup = new Map<string, string>();
|
||||
const sectionRows: InferInsertModel<typeof sections>[] = groupNames.map((groupName, index) => {
|
||||
const sectionId = createId();
|
||||
sectionByGroup.set(groupName, sectionId);
|
||||
return {
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "category" as const,
|
||||
name: groupName || "Services",
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
};
|
||||
});
|
||||
|
||||
const appRows: InferInsertModel<typeof apps>[] = [];
|
||||
const integrationRows: InferInsertModel<typeof integrations>[] = [];
|
||||
const integrationSecretRows: InferInsertModel<typeof integrationSecrets>[] = [];
|
||||
const itemRows: InferInsertModel<typeof items>[] = [];
|
||||
const itemLayoutRows: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const integrationItemRows: InferInsertModel<typeof integrationItems>[] = [];
|
||||
const gridState = new Map<string, GridPosition>();
|
||||
|
||||
for (const service of services) {
|
||||
const groupKey = service.group || "Services";
|
||||
const sectionId = sectionByGroup.get(groupKey);
|
||||
if (!sectionId) {
|
||||
warnings.push(`Skipped "${service.name}" because section "${groupKey}" was not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const appId = createId();
|
||||
const iconUrl = resolveHomepageIconUrl(service.icon, service.name);
|
||||
const pingUrl = resolvePingUrl(service);
|
||||
|
||||
appRows.push({
|
||||
id: appId,
|
||||
name: service.name,
|
||||
href: service.href ?? null,
|
||||
iconUrl,
|
||||
description: service.description ?? null,
|
||||
pingUrl,
|
||||
});
|
||||
|
||||
const grid = gridState.get(sectionId) ?? { x: 0, y: 0 };
|
||||
const itemId = createId();
|
||||
|
||||
itemRows.push({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "app",
|
||||
options: stringifyForDb({
|
||||
appId,
|
||||
openInNewTab: true,
|
||||
showTitle: true,
|
||||
layout: "column",
|
||||
descriptionDisplayMode: descriptionDisplayModes[service.description ? "present" : "absent"],
|
||||
pingEnabled: pingUrl !== null,
|
||||
}),
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
itemLayoutRows.push({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
width: 1,
|
||||
height: 1,
|
||||
xOffset: grid.x,
|
||||
yOffset: grid.y,
|
||||
});
|
||||
|
||||
gridState.set(sectionId, advanceGrid(grid, 1));
|
||||
|
||||
if (!createIntegrations) {
|
||||
for (const widget of service.widgets) {
|
||||
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
|
||||
if (!mapping) {
|
||||
unmappedWidgetTypesSet.add(widget.type);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
processServiceWidgets({
|
||||
service,
|
||||
appId,
|
||||
sectionId,
|
||||
boardId,
|
||||
layoutId,
|
||||
gridState,
|
||||
integrationRows,
|
||||
integrationSecretRows,
|
||||
itemRows,
|
||||
itemLayoutRows,
|
||||
integrationItemRows,
|
||||
warnings,
|
||||
unmappedWidgetTypesSet,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apps: appRows,
|
||||
integrations: integrationRows,
|
||||
integrationSecrets: integrationSecretRows,
|
||||
board,
|
||||
layouts: layoutRows,
|
||||
sections: sectionRows,
|
||||
items: itemRows,
|
||||
itemLayouts: itemLayoutRows,
|
||||
integrationItems: integrationItemRows,
|
||||
warnings,
|
||||
unmappedWidgetTypes: [...unmappedWidgetTypesSet].sort(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface HomepageService {
|
||||
group: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
href?: string;
|
||||
description?: string;
|
||||
ping?: string;
|
||||
siteMonitor?: string;
|
||||
widgets: HomepageWidget[];
|
||||
}
|
||||
|
||||
export interface HomepageWidget {
|
||||
type: string;
|
||||
url?: string;
|
||||
key?: string;
|
||||
fields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ParseServicesYamlResult =
|
||||
| { success: true; services: HomepageService[] }
|
||||
| { success: false; error: string };
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { homepageWidgetMap, integrationDefs, integrationKinds } from "@homarr/definitions";
|
||||
|
||||
describe("homepageWidgetMap", () => {
|
||||
test("returns mapping for known homepage widget types", () => {
|
||||
const sonarr = homepageWidgetMap.sonarr;
|
||||
expect(sonarr).toMatchObject({
|
||||
integrationKind: "sonarr",
|
||||
widgetKind: "downloads",
|
||||
secretFieldMap: { key: "apiKey" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null for unknown widget types", () => {
|
||||
expect(homepageWidgetMap.unknownservice).toBeUndefined();
|
||||
expect(homepageWidgetMap.bazarr).toBeNull();
|
||||
expect(homepageWidgetMap.tautulli).toBeNull();
|
||||
});
|
||||
|
||||
test("only maps integration kinds that exist in integrationDefs", () => {
|
||||
const invalidKinds: string[] = [];
|
||||
|
||||
for (const mapping of Object.values(homepageWidgetMap)) {
|
||||
if (!mapping) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!integrationKinds.includes(mapping.integrationKind)) {
|
||||
invalidKinds.push(mapping.integrationKind);
|
||||
}
|
||||
|
||||
expect(integrationDefs[mapping.integrationKind]).toBeDefined();
|
||||
}
|
||||
|
||||
expect(invalidKinds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecretWithKey, encryptSecret } from "@homarr/common/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrations,
|
||||
integrationSecrets,
|
||||
searchEngines,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { apps } from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
|
||||
import { GROUP_REF_FIELDS, USER_DIRECT_FIELDS, USER_REF_FIELDS, refFieldToDbColumn } from "../entity-fields";
|
||||
import { resolveEntities } from "../resolve-entities";
|
||||
import type { HomarrBundleBoard, HomarrConfigBundle } from "../schema";
|
||||
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
|
||||
import { loadExistingEntitiesAsync } from "./load-existing-entities";
|
||||
|
||||
export type ConfigImportReport = {
|
||||
boards: number;
|
||||
apps: number;
|
||||
integrations: number;
|
||||
groups: number;
|
||||
users: number;
|
||||
searchEngines: number;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const reEncryptSecret = (encryptedValue: string, sourceKeyHex: string): `${string}.${string}` => {
|
||||
const sourceKey = Buffer.from(sourceKeyHex, "hex");
|
||||
const decrypted = decryptSecretWithKey(encryptedValue as `${string}.${string}`, sourceKey);
|
||||
return encryptSecret(decrypted);
|
||||
};
|
||||
|
||||
const resolveRef = (map: Map<string, string>, ref: string | null | undefined): string | null => {
|
||||
if (!ref) return null;
|
||||
return map.get(ref) ?? null;
|
||||
};
|
||||
|
||||
const resolveApps = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingApps: { id: string; name: string; iconUrl: string; href: string | null }[],
|
||||
) =>
|
||||
resolveEntities(bundle.apps, existingApps, {
|
||||
getRef: (app) => app.ref,
|
||||
match: (bundle, existing) =>
|
||||
existing.name === bundle.name && existing.iconUrl === bundle.iconUrl && existing.href === bundle.href,
|
||||
toInsertRow: (app, id): (typeof apps.$inferInsert) => ({
|
||||
id,
|
||||
name: app.name,
|
||||
iconUrl: app.iconUrl,
|
||||
href: app.href,
|
||||
description: app.description ?? null,
|
||||
pingUrl: app.pingUrl ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
const resolveIntegrations = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingIntegrations: { id: string; kind: string; name: string; url: string }[],
|
||||
) => {
|
||||
const result = resolveEntities(bundle.integrations, existingIntegrations, {
|
||||
getRef: (i) => i.ref,
|
||||
match: (bundle, existing) =>
|
||||
existing.kind === bundle.kind && existing.name === bundle.name && existing.url === bundle.url,
|
||||
toInsertRow: (integration, id): (typeof integrations.$inferInsert) => ({
|
||||
id,
|
||||
kind: integration.kind,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
}),
|
||||
});
|
||||
|
||||
const secretRows: (typeof integrationSecrets.$inferInsert)[] = [];
|
||||
const secretWarnings: string[] = [];
|
||||
const sourceKey = bundle.encryptionKey;
|
||||
|
||||
for (const bundleIntegration of bundle.integrations) {
|
||||
const newId = result.refToId.get(bundleIntegration.ref);
|
||||
if (!newId || existingIntegrations.some((e) => e.id === newId)) continue;
|
||||
|
||||
for (const secret of bundleIntegration.secrets) {
|
||||
try {
|
||||
const reEncrypted = reEncryptSecret(secret.value, sourceKey);
|
||||
secretRows.push({
|
||||
integrationId: newId,
|
||||
kind: secret.kind as IntegrationSecretKind,
|
||||
value: reEncrypted,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} catch {
|
||||
secretWarnings.push(`Failed to re-encrypt secret "${secret.kind}" for integration "${bundleIntegration.name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
secretRows,
|
||||
warnings: [...result.warnings, ...secretWarnings],
|
||||
};
|
||||
};
|
||||
|
||||
const resolveBoards = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingBoards: { id: string; name: string }[],
|
||||
) => {
|
||||
const boardRefToId = new Map<string, string>();
|
||||
const boardsToCreate = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
const existingBoardNames = new Set(existingBoards.map((b) => b.name));
|
||||
|
||||
for (const bundleBoard of bundle.boards) {
|
||||
if (existingBoardNames.has(bundleBoard.name)) {
|
||||
const existing = existingBoards.find((b) => b.name === bundleBoard.name);
|
||||
if (existing) {
|
||||
boardRefToId.set(bundleBoard.name, existing.id);
|
||||
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, existing.id);
|
||||
}
|
||||
warnings.push(`Board "${bundleBoard.name}" already exists and was skipped`);
|
||||
continue;
|
||||
}
|
||||
const boardId = createId();
|
||||
boardRefToId.set(bundleBoard.name, boardId);
|
||||
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, boardId);
|
||||
boardsToCreate.add(bundleBoard.name);
|
||||
}
|
||||
|
||||
return { boardRefToId, boardsToCreate, warnings };
|
||||
};
|
||||
|
||||
const resolveSearchEngines = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingSearchEngines: { id: string; short: string }[],
|
||||
integrationRefToId: Map<string, string>,
|
||||
) => {
|
||||
const result = resolveEntities(bundle.searchEngines, existingSearchEngines, {
|
||||
getRef: (se) => se.ref,
|
||||
match: (bundle, existing) => existing.short === bundle.short,
|
||||
toInsertRow: (se, id): (typeof searchEngines.$inferInsert) => ({
|
||||
id,
|
||||
iconUrl: se.iconUrl,
|
||||
name: se.name,
|
||||
short: se.short,
|
||||
description: se.description ?? null,
|
||||
urlTemplate: se.urlTemplate ?? null,
|
||||
type: se.type as never,
|
||||
integrationId: resolveRef(integrationRefToId, se.integrationRef),
|
||||
}),
|
||||
skipMessage: (se) => `Search engine "${se.name}" (short: ${se.short}) already exists and was skipped`,
|
||||
});
|
||||
|
||||
for (const existing of existingSearchEngines) {
|
||||
result.refToId.set(existing.id, existing.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses USER_DIRECT_FIELDS and USER_REF_FIELDS to build user insert rows.
|
||||
* Adding a new user preference to USER_DIRECT_FIELDS automatically
|
||||
* includes it here — no other changes needed.
|
||||
*/
|
||||
const resolveUsers = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingUsers: { id: string; email: string | null }[],
|
||||
refMaps: Record<string, Map<string, string>>,
|
||||
) =>
|
||||
resolveEntities(bundle.users ?? [], existingUsers, {
|
||||
getRef: (u) => u.ref,
|
||||
match: (bundle, existing) => Boolean(bundle.email) && existing.email === bundle.email,
|
||||
toInsertRow: (bundleUser, id): (typeof users.$inferInsert) => {
|
||||
const row: Record<string, unknown> = { id };
|
||||
|
||||
for (const field of USER_DIRECT_FIELDS) {
|
||||
row[field] = (bundleUser as Record<string, unknown>)[field] ?? null;
|
||||
}
|
||||
|
||||
for (const [refField, mapName] of Object.entries(USER_REF_FIELDS)) {
|
||||
const dbCol = refFieldToDbColumn(refField);
|
||||
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleUser as Record<string, unknown>)[refField] as string);
|
||||
}
|
||||
|
||||
return row as typeof users.$inferInsert;
|
||||
},
|
||||
});
|
||||
|
||||
const resolveGroups = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingGroups: { id: string; name: string }[],
|
||||
existingMembershipKeys: Set<string>,
|
||||
refMaps: Record<string, Map<string, string>>,
|
||||
) => {
|
||||
const result = resolveEntities(bundle.groups, existingGroups, {
|
||||
getRef: (g) => g.ref,
|
||||
match: (bundle, existing) => existing.name === bundle.name,
|
||||
toInsertRow: (bundleGroup, id): (typeof groups.$inferInsert) => {
|
||||
const row: Record<string, unknown> = { id, name: bundleGroup.name, position: bundleGroup.position };
|
||||
|
||||
for (const [refField, mapName] of Object.entries(GROUP_REF_FIELDS)) {
|
||||
const dbCol = refFieldToDbColumn(refField);
|
||||
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleGroup as Record<string, unknown>)[refField] as string);
|
||||
}
|
||||
|
||||
return row as typeof groups.$inferInsert;
|
||||
},
|
||||
skipMessage: (g) => `Group "${g.name}" already exists and was skipped`,
|
||||
});
|
||||
|
||||
const groupPermRows: (typeof groupPermissions.$inferInsert)[] = [];
|
||||
const boardGroupPermRows: (typeof boardGroupPermissions.$inferInsert)[] = [];
|
||||
const integrationGroupPermRows: (typeof integrationGroupPermissions.$inferInsert)[] = [];
|
||||
const memberRows: (typeof groupMembers.$inferInsert)[] = [];
|
||||
const extraWarnings: string[] = [];
|
||||
|
||||
for (const bundleGroup of bundle.groups) {
|
||||
const groupId = result.refToId.get(bundleGroup.ref);
|
||||
if (!groupId || existingGroups.some((g) => g.id === groupId)) continue;
|
||||
|
||||
for (const perm of bundleGroup.permissions) {
|
||||
groupPermRows.push({ groupId, permission: perm as never });
|
||||
}
|
||||
}
|
||||
|
||||
for (const bundleGroup of bundle.groups) {
|
||||
const groupId = result.refToId.get(bundleGroup.ref);
|
||||
if (!groupId) continue;
|
||||
|
||||
for (const bp of bundleGroup.boardPermissions) {
|
||||
const resolvedBoardId = resolveRef(refMaps.boards!, bp.boardRef);
|
||||
if (!resolvedBoardId) {
|
||||
extraWarnings.push(`Board permission for group "${bundleGroup.name}" references unknown board, skipped`);
|
||||
continue;
|
||||
}
|
||||
boardGroupPermRows.push({ groupId, boardId: resolvedBoardId, permission: bp.permission as never });
|
||||
}
|
||||
|
||||
for (const ip of bundleGroup.integrationPermissions) {
|
||||
const resolvedIntegrationId = resolveRef(refMaps.integrations!, ip.integrationRef);
|
||||
if (resolvedIntegrationId) {
|
||||
integrationGroupPermRows.push({ groupId, integrationId: resolvedIntegrationId, permission: ip.permission as never });
|
||||
}
|
||||
}
|
||||
|
||||
for (const memberRef of bundleGroup.memberRefs ?? []) {
|
||||
const userId = refMaps.users!.get(memberRef);
|
||||
if (!userId) continue;
|
||||
const key = `${groupId}:${userId}`;
|
||||
if (existingMembershipKeys.has(key)) continue;
|
||||
memberRows.push({ groupId, userId });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
groupPermRows,
|
||||
boardGroupPermRows,
|
||||
integrationGroupPermRows,
|
||||
memberRows,
|
||||
warnings: [...result.warnings, ...extraWarnings],
|
||||
};
|
||||
};
|
||||
|
||||
const resolveServerSettings = (
|
||||
bundle: HomarrConfigBundle,
|
||||
existingSettings: { settingKey: string; value: string }[],
|
||||
) => {
|
||||
const existingKeys = new Set(existingSettings.map((s) => s.settingKey));
|
||||
const toInsert: (typeof serverSettings.$inferInsert)[] = [];
|
||||
const toUpdate: { key: string; value: string }[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(bundle.serverSettings)) {
|
||||
const serialized = SuperJSON.stringify(value);
|
||||
if (existingKeys.has(key)) {
|
||||
toUpdate.push({ key, value: serialized });
|
||||
} else {
|
||||
toInsert.push({ settingKey: key, value: serialized });
|
||||
}
|
||||
}
|
||||
|
||||
return { toInsert, toUpdate };
|
||||
};
|
||||
|
||||
const buildBoardInserts = (
|
||||
bundle: HomarrConfigBundle,
|
||||
boardsToCreate: Set<string>,
|
||||
boardRefToId: Map<string, string>,
|
||||
appRefToId: Map<string, string>,
|
||||
integrationRefToId: Map<string, string>,
|
||||
creatorId: string | null,
|
||||
insertCollection: Pick<ReturnType<typeof createDbInsertCollectionForTransaction>, "boards" | "layouts" | "sections" | "sectionLayouts" | "items" | "itemLayouts" | "integrationItems">,
|
||||
) => {
|
||||
let boardsCreated = 0;
|
||||
|
||||
for (const bundleBoard of bundle.boards) {
|
||||
if (!boardsToCreate.has(bundleBoard.name)) continue;
|
||||
const boardId = boardRefToId.get(bundleBoard.name);
|
||||
if (!boardId) continue;
|
||||
|
||||
boardsCreated++;
|
||||
const sourceBoard = bundleBoard as HomarrBundleBoard;
|
||||
const layoutRefToId = new Map(sourceBoard.layouts.map((l) => [l.ref, createId()]));
|
||||
const sectionRefToId = new Map(sourceBoard.sections.map((s) => [s.ref, createId()]));
|
||||
const itemRefToId = new Map(sourceBoard.items.map((i) => [i.ref, createId()]));
|
||||
|
||||
insertCollection.boards.push(buildBoardInsertRow(boardId, sourceBoard.name, creatorId, sourceBoard.settings));
|
||||
|
||||
for (const layout of sourceBoard.layouts) {
|
||||
insertCollection.layouts.push({
|
||||
id: mustGet(layoutRefToId, layout.ref),
|
||||
boardId,
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
breakpoint: layout.breakpoint,
|
||||
});
|
||||
}
|
||||
|
||||
for (const section of sourceBoard.sections) {
|
||||
insertCollection.sections.push({
|
||||
id: mustGet(sectionRefToId, section.ref),
|
||||
boardId,
|
||||
kind: section.kind,
|
||||
name: section.name ?? null,
|
||||
xOffset: section.xOffset ?? null,
|
||||
yOffset: section.yOffset ?? null,
|
||||
options: section.options ? stringifyForDb(section.options) : undefined,
|
||||
});
|
||||
|
||||
for (const layout of section.layouts ?? []) {
|
||||
insertCollection.sectionLayouts.push({
|
||||
sectionId: mustGet(sectionRefToId, section.ref),
|
||||
layoutId: mustGet(layoutRefToId, layout.layoutRef),
|
||||
parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef),
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of sourceBoard.items) {
|
||||
const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record<string, unknown>;
|
||||
insertCollection.items.push({
|
||||
id: mustGet(itemRefToId, item.ref),
|
||||
boardId,
|
||||
kind: item.kind,
|
||||
options: stringifyForDb(optionsWithRefs),
|
||||
advancedOptions: stringifyForDb(item.advancedOptions),
|
||||
});
|
||||
|
||||
for (const layout of item.layouts) {
|
||||
insertCollection.itemLayouts.push({
|
||||
itemId: mustGet(itemRefToId, item.ref),
|
||||
sectionId: mustGet(sectionRefToId, layout.sectionRef),
|
||||
layoutId: mustGet(layoutRefToId, layout.layoutRef),
|
||||
xOffset: layout.x,
|
||||
yOffset: layout.y,
|
||||
width: layout.w,
|
||||
height: layout.h,
|
||||
});
|
||||
}
|
||||
|
||||
for (const integrationRef of item.integrationRefs) {
|
||||
const integrationId = integrationRefToId.get(integrationRef);
|
||||
if (integrationId) {
|
||||
insertCollection.integrationItems.push({
|
||||
itemId: mustGet(itemRefToId, item.ref),
|
||||
integrationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return boardsCreated;
|
||||
};
|
||||
|
||||
export const importFullConfigAsync = async (
|
||||
db: Database,
|
||||
bundle: HomarrConfigBundle,
|
||||
creatorId: string | null,
|
||||
): Promise<ConfigImportReport> => {
|
||||
const existing = await loadExistingEntitiesAsync(db);
|
||||
const existingMembershipKeys = new Set(existing.groupMembers.map((gm) => `${gm.groupId}:${gm.userId}`));
|
||||
|
||||
const resolvedApps = resolveApps(bundle, existing.apps);
|
||||
const resolvedIntegrations = resolveIntegrations(bundle, existing.integrations);
|
||||
const resolvedBoardsResult = resolveBoards(bundle, existing.boards);
|
||||
const resolvedSearchEngines = resolveSearchEngines(bundle, existing.searchEngines, resolvedIntegrations.refToId);
|
||||
|
||||
const refMaps: Record<string, Map<string, string>> = {
|
||||
boards: resolvedBoardsResult.boardRefToId,
|
||||
searchEngines: resolvedSearchEngines.refToId,
|
||||
integrations: resolvedIntegrations.refToId,
|
||||
users: new Map<string, string>(),
|
||||
};
|
||||
|
||||
const resolvedUsers = resolveUsers(bundle, existing.users, refMaps);
|
||||
refMaps.users = resolvedUsers.refToId;
|
||||
|
||||
const resolvedGroups = resolveGroups(bundle, existing.groups, existingMembershipKeys, refMaps);
|
||||
|
||||
const resolvedSettings = resolveServerSettings(bundle, existing.serverSettings);
|
||||
|
||||
const insertCollection = createDbInsertCollectionForTransaction([
|
||||
"apps",
|
||||
"integrations",
|
||||
"integrationSecrets",
|
||||
"boards",
|
||||
"layouts",
|
||||
"sections",
|
||||
"sectionLayouts",
|
||||
"items",
|
||||
"itemLayouts",
|
||||
"integrationItems",
|
||||
"searchEngines",
|
||||
"users",
|
||||
"groups",
|
||||
"groupPermissions",
|
||||
"groupMembers",
|
||||
"boardGroupPermissions",
|
||||
"integrationGroupPermissions",
|
||||
]);
|
||||
|
||||
insertCollection.apps.push(...resolvedApps.rows);
|
||||
insertCollection.integrations.push(...resolvedIntegrations.rows);
|
||||
insertCollection.integrationSecrets.push(...resolvedIntegrations.secretRows);
|
||||
insertCollection.searchEngines.push(...resolvedSearchEngines.rows);
|
||||
insertCollection.users.push(...resolvedUsers.rows);
|
||||
insertCollection.groups.push(...resolvedGroups.rows);
|
||||
insertCollection.groupPermissions.push(...resolvedGroups.groupPermRows);
|
||||
insertCollection.groupMembers.push(...resolvedGroups.memberRows);
|
||||
insertCollection.boardGroupPermissions.push(...resolvedGroups.boardGroupPermRows);
|
||||
insertCollection.integrationGroupPermissions.push(...resolvedGroups.integrationGroupPermRows);
|
||||
|
||||
const boardsCreated = buildBoardInserts(
|
||||
bundle,
|
||||
resolvedBoardsResult.boardsToCreate,
|
||||
resolvedBoardsResult.boardRefToId,
|
||||
resolvedApps.refToId,
|
||||
resolvedIntegrations.refToId,
|
||||
creatorId,
|
||||
insertCollection,
|
||||
);
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(innerDb, dbSchema) {
|
||||
await innerDb.transaction(async (trx) => {
|
||||
await insertCollection.insertAllAsync(trx);
|
||||
if (resolvedSettings.toInsert.length > 0) {
|
||||
await trx.insert(dbSchema.serverSettings).values(resolvedSettings.toInsert as never);
|
||||
}
|
||||
for (const setting of resolvedSettings.toUpdate) {
|
||||
await trx
|
||||
.update(dbSchema.serverSettings)
|
||||
.set({ value: setting.value })
|
||||
.where(eq(dbSchema.serverSettings.settingKey, setting.key));
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(innerDb) {
|
||||
innerDb.transaction((trx) => {
|
||||
insertCollection.insertAll(trx);
|
||||
if (resolvedSettings.toInsert.length > 0) {
|
||||
trx.insert(serverSettings).values(resolvedSettings.toInsert as never).run();
|
||||
}
|
||||
for (const setting of resolvedSettings.toUpdate) {
|
||||
trx
|
||||
.update(serverSettings)
|
||||
.set({ value: setting.value })
|
||||
.where(eq(serverSettings.settingKey, setting.key))
|
||||
.run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const warnings = [
|
||||
...resolvedIntegrations.warnings,
|
||||
...resolvedBoardsResult.warnings,
|
||||
...resolvedSearchEngines.warnings,
|
||||
...resolvedGroups.warnings,
|
||||
];
|
||||
|
||||
return {
|
||||
boards: boardsCreated,
|
||||
apps: resolvedApps.rows.length,
|
||||
integrations: resolvedIntegrations.rows.length,
|
||||
groups: resolvedGroups.rows.length,
|
||||
users: resolvedUsers.rows.length,
|
||||
searchEngines: resolvedSearchEngines.rows.length,
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { parseBundleJson } from "./parse-bundle";
|
||||
export { prepareBoardImport, type PreparedBundleImport } from "./prepare-board";
|
||||
export { importBoardBundleAsync, type BundleImportReport } from "./insert";
|
||||
export { doAppsMatch } from "./match-apps";
|
||||
@@ -0,0 +1,79 @@
|
||||
import { handleTransactionsAsync } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
|
||||
import type { HomarrBundle } from "../schema";
|
||||
import type { PreparedBundleImport } from "./prepare-board";
|
||||
import { prepareBoardImport } from "./prepare-board";
|
||||
|
||||
export type BundleImportReport = {
|
||||
created: {
|
||||
apps: number;
|
||||
items: number;
|
||||
};
|
||||
warnings: string[];
|
||||
boardId: string;
|
||||
};
|
||||
|
||||
export const importBoardBundleAsync = async (
|
||||
db: Database,
|
||||
bundle: HomarrBundle,
|
||||
boardName: string,
|
||||
creatorId: string,
|
||||
existingApps: Awaited<ReturnType<typeof db.query.apps.findMany>>,
|
||||
existingIntegrations: Awaited<ReturnType<typeof db.query.integrations.findMany>>,
|
||||
integrationIdsWithAccess: string[],
|
||||
hasAccessForAll: boolean,
|
||||
): Promise<BundleImportReport> => {
|
||||
const prepared = prepareBoardImport(
|
||||
bundle,
|
||||
boardName,
|
||||
creatorId,
|
||||
existingApps,
|
||||
existingIntegrations,
|
||||
integrationIdsWithAccess,
|
||||
hasAccessForAll,
|
||||
);
|
||||
|
||||
await insertPreparedBundleAsync(db, prepared);
|
||||
|
||||
return {
|
||||
created: {
|
||||
apps: prepared.createdAppsCount,
|
||||
items: prepared.createdItemsCount,
|
||||
},
|
||||
warnings: prepared.warnings,
|
||||
boardId: prepared.board.id,
|
||||
};
|
||||
};
|
||||
|
||||
const insertPreparedBundleAsync = async (db: Database, prepared: PreparedBundleImport) => {
|
||||
const insertCollection = createDbInsertCollectionForTransaction([
|
||||
"apps",
|
||||
"boards",
|
||||
"layouts",
|
||||
"sections",
|
||||
"sectionLayouts",
|
||||
"items",
|
||||
"itemLayouts",
|
||||
"integrationItems",
|
||||
]);
|
||||
|
||||
insertCollection.apps.push(...prepared.apps);
|
||||
insertCollection.boards.push(prepared.board);
|
||||
insertCollection.layouts.push(...prepared.layouts);
|
||||
insertCollection.sections.push(...prepared.sections);
|
||||
insertCollection.sectionLayouts.push(...prepared.sectionLayouts);
|
||||
insertCollection.items.push(...prepared.items);
|
||||
insertCollection.itemLayouts.push(...prepared.itemLayouts);
|
||||
insertCollection.integrationItems.push(...prepared.integrationItems);
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(innerDb) {
|
||||
await insertCollection.insertAllAsync(innerDb);
|
||||
},
|
||||
handleSync(innerDb) {
|
||||
insertCollection.insertAll(innerDb);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Database, InferSelectModel } from "@homarr/db";
|
||||
import type { groupMembers, groups, searchEngines, serverSettings, users } from "@homarr/db/schema";
|
||||
import { groupMembers as groupMembersTable, groups as groupsTable, searchEngines as searchEnginesTable, serverSettings as serverSettingsTable, users as usersTable } from "@homarr/db/schema";
|
||||
|
||||
export type ExistingEntities = {
|
||||
apps: Awaited<ReturnType<Database["query"]["apps"]["findMany"]>>;
|
||||
integrations: Awaited<ReturnType<Database["query"]["integrations"]["findMany"]>>;
|
||||
boards: Awaited<ReturnType<Database["query"]["boards"]["findMany"]>>;
|
||||
groups: InferSelectModel<typeof groups>[];
|
||||
searchEngines: InferSelectModel<typeof searchEngines>[];
|
||||
users: InferSelectModel<typeof users>[];
|
||||
groupMembers: InferSelectModel<typeof groupMembers>[];
|
||||
serverSettings: InferSelectModel<typeof serverSettings>[];
|
||||
};
|
||||
|
||||
export const loadExistingEntitiesAsync = async (db: Database): Promise<ExistingEntities> => {
|
||||
const [appsResult, integrationsResult, boardsResult, groupsResult, searchEnginesResult, usersResult, groupMembersResult, serverSettingsResult] =
|
||||
await Promise.all([
|
||||
db.query.apps.findMany(),
|
||||
db.query.integrations.findMany(),
|
||||
db.query.boards.findMany(),
|
||||
db.select().from(groupsTable),
|
||||
db.select().from(searchEnginesTable),
|
||||
db.select().from(usersTable),
|
||||
db.select().from(groupMembersTable),
|
||||
db.select().from(serverSettingsTable),
|
||||
]);
|
||||
|
||||
return {
|
||||
apps: appsResult,
|
||||
integrations: integrationsResult,
|
||||
boards: boardsResult,
|
||||
groups: groupsResult,
|
||||
searchEngines: searchEnginesResult,
|
||||
users: usersResult,
|
||||
groupMembers: groupMembersResult,
|
||||
serverSettings: serverSettingsResult,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { InferSelectModel } from "@homarr/db";
|
||||
import type { apps } from "@homarr/db/schema";
|
||||
|
||||
export const doAppsMatch = (
|
||||
app1: Omit<InferSelectModel<typeof apps>, "id">,
|
||||
app2: Omit<InferSelectModel<typeof apps>, "id">,
|
||||
) => {
|
||||
return (
|
||||
app1.name === app2.name &&
|
||||
app1.iconUrl === app2.iconUrl &&
|
||||
app1.description === app2.description &&
|
||||
app1.href === app2.href
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { HomarrBundle } from "../schema";
|
||||
import { homarrBundleSchema } from "../schema";
|
||||
|
||||
export const parseBundleJson = (content: string): HomarrBundle => {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
return homarrBundleSchema.parse(parsed);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { assessBundleCompatibility } from "../config-bundle-compat";
|
||||
import type { HomarrConfigBundle } from "../schema";
|
||||
import type { ConfigEntityCounts, ConfigImportPreview } from "../types";
|
||||
import { emptyCounts } from "../types";
|
||||
import { loadExistingEntitiesAsync } from "./load-existing-entities";
|
||||
import { doAppsMatch } from "./match-apps";
|
||||
|
||||
const emptyPreview = (
|
||||
compatibility: ConfigImportPreview["compatibility"],
|
||||
meta: ConfigImportPreview["meta"],
|
||||
warnings: string[] = [],
|
||||
): ConfigImportPreview => ({
|
||||
compatibility,
|
||||
meta,
|
||||
toCreate: emptyCounts(),
|
||||
toReuse: { apps: 0, integrations: 0, users: 0 },
|
||||
toSkip: { boards: 0, groups: 0, searchEngines: 0 },
|
||||
toUpdate: { serverSettings: 0 },
|
||||
warnings,
|
||||
});
|
||||
|
||||
const extractMetaFromUnknown = (parsed: unknown): ConfigImportPreview["meta"] => {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
return {
|
||||
exportedAt: typeof obj.exportedAt === "string" ? obj.exportedAt : "",
|
||||
homarrVersion: typeof obj.homarrVersion === "string" ? obj.homarrVersion : "",
|
||||
bundleVersion: typeof obj.version === "string" ? obj.version : "",
|
||||
};
|
||||
};
|
||||
|
||||
const integrationMatches = (
|
||||
bundleIntegration: HomarrConfigBundle["integrations"][number],
|
||||
existing: { kind: IntegrationKind; name: string; url: string },
|
||||
) =>
|
||||
bundleIntegration.kind === existing.kind &&
|
||||
bundleIntegration.name === existing.name &&
|
||||
bundleIntegration.url === existing.url;
|
||||
|
||||
export const previewImportFullConfigAsync = async (
|
||||
db: Database,
|
||||
content: string,
|
||||
currentHomarrVersion: string,
|
||||
): Promise<ConfigImportPreview> => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return emptyPreview(
|
||||
{ status: "invalidStructure", bundleVersion: null, bundleHomarrVersion: null, currentHomarrVersion, issues: ["File is not valid JSON"] },
|
||||
{ exportedAt: "", homarrVersion: "", bundleVersion: "" },
|
||||
);
|
||||
}
|
||||
|
||||
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
|
||||
|
||||
if (!bundle || compatibility.status !== "compatible") {
|
||||
return emptyPreview(compatibility, extractMetaFromUnknown(parsed), compatibility.issues);
|
||||
}
|
||||
|
||||
const existing = await loadExistingEntitiesAsync(db);
|
||||
const existingSettingsByKey = new Map(
|
||||
existing.serverSettings.map((setting) => [setting.settingKey, setting.value] as const),
|
||||
);
|
||||
|
||||
const existingBoardNames = new Set(existing.boards.map((b) => b.name));
|
||||
const existingGroupNames = new Set(existing.groups.map((g) => g.name));
|
||||
const existingSearchEngineShorts = new Set(existing.searchEngines.map((se) => se.short));
|
||||
const existingUserEmails = new Set(existing.users.filter((u) => u.email).map((u) => u.email!));
|
||||
|
||||
const toCreate = emptyCounts();
|
||||
const warnings: string[] = [];
|
||||
let reuseApps = 0;
|
||||
let reuseIntegrations = 0;
|
||||
let reuseUsers = 0;
|
||||
let skipBoards = 0;
|
||||
let skipGroups = 0;
|
||||
let skipSearchEngines = 0;
|
||||
let updateServerSettings = 0;
|
||||
|
||||
for (const bundleApp of bundle.apps) {
|
||||
const matched = existing.apps.find((app) =>
|
||||
doAppsMatch(app, {
|
||||
name: bundleApp.name,
|
||||
iconUrl: bundleApp.iconUrl,
|
||||
description: bundleApp.description ?? null,
|
||||
href: bundleApp.href,
|
||||
pingUrl: bundleApp.pingUrl ?? null,
|
||||
}),
|
||||
);
|
||||
if (matched) {
|
||||
reuseApps += 1;
|
||||
continue;
|
||||
}
|
||||
toCreate.apps += 1;
|
||||
}
|
||||
|
||||
for (const bundleIntegration of bundle.integrations) {
|
||||
const matched = existing.integrations.find((i) => integrationMatches(bundleIntegration, i));
|
||||
if (matched) {
|
||||
reuseIntegrations += 1;
|
||||
continue;
|
||||
}
|
||||
toCreate.integrations += 1;
|
||||
toCreate.secrets += bundleIntegration.secrets.length;
|
||||
}
|
||||
|
||||
for (const bundleBoard of bundle.boards) {
|
||||
if (existingBoardNames.has(bundleBoard.name)) {
|
||||
skipBoards += 1;
|
||||
warnings.push(`Board "${bundleBoard.name}" already exists and will be skipped`);
|
||||
continue;
|
||||
}
|
||||
toCreate.boards += 1;
|
||||
toCreate.widgets += bundleBoard.items.length;
|
||||
toCreate.sections += bundleBoard.sections.length;
|
||||
toCreate.layouts += bundleBoard.layouts.length;
|
||||
}
|
||||
|
||||
for (const bundleGroup of bundle.groups) {
|
||||
if (existingGroupNames.has(bundleGroup.name)) {
|
||||
skipGroups += 1;
|
||||
warnings.push(`Group "${bundleGroup.name}" already exists and will be skipped`);
|
||||
continue;
|
||||
}
|
||||
toCreate.groups += 1;
|
||||
}
|
||||
|
||||
for (const bundleSearchEngine of bundle.searchEngines) {
|
||||
if (existingSearchEngineShorts.has(bundleSearchEngine.short)) {
|
||||
skipSearchEngines += 1;
|
||||
warnings.push(`Search engine "${bundleSearchEngine.name}" already exists and will be skipped`);
|
||||
continue;
|
||||
}
|
||||
toCreate.searchEngines += 1;
|
||||
}
|
||||
|
||||
for (const bundleUser of bundle.users ?? []) {
|
||||
if (bundleUser.email && existingUserEmails.has(bundleUser.email)) {
|
||||
reuseUsers += 1;
|
||||
continue;
|
||||
}
|
||||
toCreate.users += 1;
|
||||
}
|
||||
|
||||
for (const [settingKey, value] of Object.entries(bundle.serverSettings)) {
|
||||
const serialized = SuperJSON.stringify(value);
|
||||
const existingValue = existingSettingsByKey.get(settingKey);
|
||||
if (existingValue === undefined) {
|
||||
toCreate.serverSettings += 1;
|
||||
continue;
|
||||
}
|
||||
if (existingValue !== serialized) {
|
||||
updateServerSettings += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
compatibility,
|
||||
meta: {
|
||||
exportedAt: bundle.exportedAt,
|
||||
homarrVersion: bundle.homarrVersion,
|
||||
bundleVersion: bundle.version,
|
||||
},
|
||||
toCreate,
|
||||
toReuse: { apps: reuseApps, integrations: reuseIntegrations, users: reuseUsers },
|
||||
toSkip: { boards: skipBoards, groups: skipGroups, searchEngines: skipSearchEngines },
|
||||
toUpdate: { serverSettings: updateServerSettings },
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import type { InferInsertModel, InferSelectModel } from "@homarr/db";
|
||||
import type {
|
||||
apps,
|
||||
boards,
|
||||
integrationItems,
|
||||
integrations,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
sectionLayouts,
|
||||
sections,
|
||||
} from "@homarr/db/schema";
|
||||
|
||||
import type { HomarrBundle, HomarrBundleBoard } from "../schema";
|
||||
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
|
||||
import { doAppsMatch } from "./match-apps";
|
||||
|
||||
export type PreparedBundleImport = {
|
||||
apps: InferInsertModel<typeof apps>[];
|
||||
board: InferInsertModel<typeof boards>;
|
||||
layouts: InferInsertModel<typeof layouts>[];
|
||||
sections: InferInsertModel<typeof sections>[];
|
||||
sectionLayouts: InferInsertModel<typeof sectionLayouts>[];
|
||||
items: InferInsertModel<typeof items>[];
|
||||
itemLayouts: InferInsertModel<typeof itemLayouts>[];
|
||||
integrationItems: InferInsertModel<typeof integrationItems>[];
|
||||
warnings: string[];
|
||||
createdAppsCount: number;
|
||||
createdItemsCount: number;
|
||||
};
|
||||
|
||||
const integrationMatches = (
|
||||
bundleIntegration: HomarrBundle["integrations"][number],
|
||||
existing: InferSelectModel<typeof integrations>,
|
||||
) => {
|
||||
return (
|
||||
bundleIntegration.kind === existing.kind &&
|
||||
bundleIntegration.name === existing.name &&
|
||||
bundleIntegration.url === existing.url
|
||||
);
|
||||
};
|
||||
|
||||
export const prepareBoardImport = (
|
||||
bundle: HomarrBundle,
|
||||
boardName: string,
|
||||
creatorId: string,
|
||||
existingApps: InferSelectModel<typeof apps>[],
|
||||
existingIntegrations: InferSelectModel<typeof integrations>[],
|
||||
integrationIdsWithAccess: string[],
|
||||
hasAccessForAll: boolean,
|
||||
): PreparedBundleImport => {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (bundle.boards.length > 1) {
|
||||
warnings.push(`Bundle contains ${bundle.boards.length} boards; only the first board was imported`);
|
||||
}
|
||||
|
||||
const sourceBoard = bundle.boards[0] as HomarrBundleBoard | undefined;
|
||||
|
||||
if (!sourceBoard) {
|
||||
throw new Error("Bundle does not contain a board");
|
||||
}
|
||||
|
||||
const boardId = createId();
|
||||
const layoutRefToId = new Map(sourceBoard.layouts.map((layout) => [layout.ref, createId()]));
|
||||
const sectionRefToId = new Map(sourceBoard.sections.map((section) => [section.ref, createId()]));
|
||||
const itemRefToId = new Map(sourceBoard.items.map((item) => [item.ref, createId()]));
|
||||
|
||||
const appRefToId = new Map<string, string>();
|
||||
const appsToInsert: InferInsertModel<typeof apps>[] = [];
|
||||
let createdAppsCount = 0;
|
||||
|
||||
for (const bundleApp of bundle.apps) {
|
||||
const existingApp = existingApps.find((existing) =>
|
||||
doAppsMatch(existing, {
|
||||
name: bundleApp.name,
|
||||
iconUrl: bundleApp.iconUrl,
|
||||
description: bundleApp.description ?? null,
|
||||
href: bundleApp.href,
|
||||
pingUrl: bundleApp.pingUrl ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
if (existingApp) {
|
||||
appRefToId.set(bundleApp.ref, existingApp.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newAppId = createId();
|
||||
appRefToId.set(bundleApp.ref, newAppId);
|
||||
appsToInsert.push({
|
||||
id: newAppId,
|
||||
name: bundleApp.name,
|
||||
iconUrl: bundleApp.iconUrl,
|
||||
href: bundleApp.href,
|
||||
description: bundleApp.description ?? null,
|
||||
pingUrl: bundleApp.pingUrl ?? null,
|
||||
});
|
||||
createdAppsCount += 1;
|
||||
}
|
||||
|
||||
const integrationRefToId = new Map<string, string>();
|
||||
for (const bundleIntegration of bundle.integrations) {
|
||||
const existingIntegration = existingIntegrations.find((existing) =>
|
||||
integrationMatches(bundleIntegration, existing),
|
||||
);
|
||||
|
||||
if (!existingIntegration) {
|
||||
warnings.push(
|
||||
`Integration "${bundleIntegration.name}" (${bundleIntegration.kind}) was not found and linked widgets were skipped`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAccess = hasAccessForAll || integrationIdsWithAccess.includes(existingIntegration.id);
|
||||
if (!hasAccess) {
|
||||
warnings.push(`Integration "${bundleIntegration.name}" is not accessible and was skipped`);
|
||||
continue;
|
||||
}
|
||||
|
||||
integrationRefToId.set(bundleIntegration.ref, existingIntegration.id);
|
||||
}
|
||||
|
||||
const board: PreparedBundleImport["board"] = buildBoardInsertRow(boardId, boardName, creatorId, sourceBoard.settings);
|
||||
|
||||
const layouts = sourceBoard.layouts.map((layout) => ({
|
||||
id: mustGet(layoutRefToId, layout.ref),
|
||||
boardId,
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
breakpoint: layout.breakpoint,
|
||||
}));
|
||||
|
||||
const sections = sourceBoard.sections.map((section) => ({
|
||||
id: mustGet(sectionRefToId, section.ref),
|
||||
boardId,
|
||||
kind: section.kind,
|
||||
name: section.name ?? null,
|
||||
xOffset: section.xOffset ?? null,
|
||||
yOffset: section.yOffset ?? null,
|
||||
options: section.options ? stringifyForDb(section.options) : undefined,
|
||||
}));
|
||||
|
||||
const sectionLayouts = sourceBoard.sections.flatMap((section) =>
|
||||
(section.layouts ?? []).map((layout) => ({
|
||||
sectionId: mustGet(sectionRefToId, section.ref),
|
||||
layoutId: mustGet(layoutRefToId, layout.layoutRef),
|
||||
parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef),
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
})),
|
||||
);
|
||||
|
||||
const items = sourceBoard.items.map((item) => {
|
||||
const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record<string, unknown>;
|
||||
return {
|
||||
id: mustGet(itemRefToId, item.ref),
|
||||
boardId,
|
||||
kind: item.kind,
|
||||
options: stringifyForDb(optionsWithRefs),
|
||||
advancedOptions: stringifyForDb(item.advancedOptions),
|
||||
};
|
||||
});
|
||||
|
||||
const itemLayouts = sourceBoard.items.flatMap((item) =>
|
||||
item.layouts.map((layout) => ({
|
||||
itemId: mustGet(itemRefToId, item.ref),
|
||||
sectionId: mustGet(sectionRefToId, layout.sectionRef),
|
||||
layoutId: mustGet(layoutRefToId, layout.layoutRef),
|
||||
xOffset: layout.x,
|
||||
yOffset: layout.y,
|
||||
width: layout.w,
|
||||
height: layout.h,
|
||||
})),
|
||||
);
|
||||
|
||||
const integrationItems = sourceBoard.items.flatMap((item) =>
|
||||
item.integrationRefs
|
||||
.map((integrationRef) => integrationRefToId.get(integrationRef))
|
||||
.filter((integrationId): integrationId is string => integrationId !== undefined)
|
||||
.map((integrationId) => ({
|
||||
itemId: mustGet(itemRefToId, item.ref),
|
||||
integrationId,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
apps: appsToInsert,
|
||||
board,
|
||||
layouts,
|
||||
sections,
|
||||
sectionLayouts,
|
||||
items,
|
||||
itemLayouts,
|
||||
integrationItems,
|
||||
warnings,
|
||||
createdAppsCount,
|
||||
createdItemsCount: items.length,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
export type EntityResolverDef<TBundle, TExisting extends { id: string }, TInsert> = {
|
||||
getRef: (bundle: TBundle) => string;
|
||||
match: (bundle: TBundle, existing: TExisting) => boolean;
|
||||
toInsertRow: (bundle: TBundle, newId: string) => TInsert;
|
||||
skipMessage?: (bundle: TBundle) => string;
|
||||
};
|
||||
|
||||
export type ResolveResult<TInsert> = {
|
||||
refToId: Map<string, string>;
|
||||
rows: TInsert[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export const resolveEntities = <TBundle, TExisting extends { id: string }, TInsert>(
|
||||
bundleEntities: TBundle[],
|
||||
existingEntities: TExisting[],
|
||||
def: EntityResolverDef<TBundle, TExisting, TInsert>,
|
||||
): ResolveResult<TInsert> => {
|
||||
const refToId = new Map<string, string>();
|
||||
const rows: TInsert[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const bundleEntity of bundleEntities) {
|
||||
const existing = existingEntities.find((e) => def.match(bundleEntity, e));
|
||||
|
||||
if (existing) {
|
||||
refToId.set(def.getRef(bundleEntity), existing.id);
|
||||
if (def.skipMessage) warnings.push(def.skipMessage(bundleEntity));
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = createId();
|
||||
refToId.set(def.getRef(bundleEntity), newId);
|
||||
rows.push(def.toInsertRow(bundleEntity, newId));
|
||||
}
|
||||
|
||||
return { refToId, rows, warnings };
|
||||
};
|
||||
|
||||
export const groupByKey = <T, K extends string | number>(
|
||||
items: T[],
|
||||
getKey: (item: T) => K,
|
||||
): Map<K, T[]> => {
|
||||
const map = new Map<K, T[]>();
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(item);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
integrationKinds,
|
||||
sectionKinds,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
|
||||
import { zodEnumFromArray } from "@homarr/validation/enums";
|
||||
|
||||
const bundleVersionSchema = z.literal("1.0");
|
||||
|
||||
const bundleBoardSettingsSchema = z.object({
|
||||
pageTitle: z.string().nullable().optional(),
|
||||
metaTitle: z.string().nullable().optional(),
|
||||
logoImageUrl: z.string().nullable().optional(),
|
||||
faviconImageUrl: z.string().nullable().optional(),
|
||||
backgroundImageUrl: z.string().nullable().optional(),
|
||||
backgroundImageAttachment: z.enum(backgroundImageAttachments.values).optional(),
|
||||
backgroundImageRepeat: z.enum(backgroundImageRepeats.values).optional(),
|
||||
backgroundImageSize: z.enum(backgroundImageSizes.values).optional(),
|
||||
primaryColor: z.string(),
|
||||
secondaryColor: z.string(),
|
||||
opacity: z.number(),
|
||||
customCss: z.string().nullable().optional(),
|
||||
iconColor: z.string().nullable().optional(),
|
||||
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
|
||||
disableStatus: z.boolean().optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const bundleLayoutSchema = z.object({
|
||||
ref: z.string(),
|
||||
name: z.string(),
|
||||
columnCount: z.number(),
|
||||
breakpoint: z.number(),
|
||||
});
|
||||
|
||||
const bundleSectionLayoutSchema = z.object({
|
||||
layoutRef: z.string(),
|
||||
parentSectionRef: z.string(),
|
||||
xOffset: z.number(),
|
||||
yOffset: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
});
|
||||
|
||||
const bundleSectionSchema = z.object({
|
||||
ref: z.string(),
|
||||
kind: zodEnumFromArray(sectionKinds),
|
||||
name: z.string().nullable().optional(),
|
||||
yOffset: z.number().nullable().optional(),
|
||||
xOffset: z.number().nullable().optional(),
|
||||
options: z.record(z.string(), z.unknown()).optional(),
|
||||
layouts: z.array(bundleSectionLayoutSchema).optional(),
|
||||
});
|
||||
|
||||
const bundleItemLayoutSchema = z.object({
|
||||
layoutRef: z.string(),
|
||||
sectionRef: z.string(),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
w: z.number(),
|
||||
h: z.number(),
|
||||
});
|
||||
|
||||
const bundleItemSchema = z.object({
|
||||
ref: z.string(),
|
||||
kind: zodEnumFromArray(widgetKinds),
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
advancedOptions: itemAdvancedOptionsSchema,
|
||||
integrationRefs: z.array(z.string()),
|
||||
appRef: z.string().optional(),
|
||||
layouts: z.array(bundleItemLayoutSchema),
|
||||
});
|
||||
|
||||
const bundleBoardSchema = z.object({
|
||||
ref: z.string().optional(),
|
||||
name: z.string(),
|
||||
settings: bundleBoardSettingsSchema,
|
||||
layouts: z.array(bundleLayoutSchema),
|
||||
sections: z.array(bundleSectionSchema),
|
||||
items: z.array(bundleItemSchema),
|
||||
});
|
||||
|
||||
const bundleAppSchema = z.object({
|
||||
ref: z.string(),
|
||||
name: z.string(),
|
||||
href: z.string().nullable(),
|
||||
iconUrl: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
pingUrl: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const bundleIntegrationSchema = z.object({
|
||||
ref: z.string(),
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
secretKinds: z.array(z.string()),
|
||||
secrets: z.literal("REDACTED"),
|
||||
});
|
||||
|
||||
export const homarrBundleSchema = z.object({
|
||||
version: bundleVersionSchema,
|
||||
exportedAt: z.string(),
|
||||
homarrVersion: z.string(),
|
||||
boards: z.array(bundleBoardSchema).min(1),
|
||||
apps: z.array(bundleAppSchema),
|
||||
integrations: z.array(bundleIntegrationSchema),
|
||||
});
|
||||
|
||||
export type HomarrBundle = z.infer<typeof homarrBundleSchema>;
|
||||
export type HomarrBundleBoard = z.infer<typeof bundleBoardSchema>;
|
||||
export type HomarrBundleApp = z.infer<typeof bundleAppSchema>;
|
||||
export type HomarrBundleIntegration = z.infer<typeof bundleIntegrationSchema>;
|
||||
|
||||
const configBundleSecretSchema = z.object({
|
||||
kind: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const configBundleIntegrationSchema = z.object({
|
||||
ref: z.string(),
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
secrets: z.array(configBundleSecretSchema),
|
||||
});
|
||||
|
||||
const configBundleSearchEngineSchema = z.object({
|
||||
ref: z.string(),
|
||||
iconUrl: z.string(),
|
||||
name: z.string(),
|
||||
short: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
urlTemplate: z.string().nullable().optional(),
|
||||
type: z.string(),
|
||||
integrationRef: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const configBundleUserSchema = z
|
||||
.object({
|
||||
ref: z.string(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
password: z.string().nullable(),
|
||||
provider: z.string(),
|
||||
homeBoardRef: z.string().nullable(),
|
||||
mobileHomeBoardRef: z.string().nullable(),
|
||||
defaultSearchEngineRef: z.string().nullable(),
|
||||
colorScheme: z.string(),
|
||||
firstDayOfWeek: z.number(),
|
||||
openSearchInNewTab: z.boolean(),
|
||||
ddgBangs: z.boolean(),
|
||||
pingIconsEnabled: z.boolean(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
const configBundleGroupSchema = z.object({
|
||||
ref: z.string(),
|
||||
name: z.string(),
|
||||
position: z.number(),
|
||||
ownerRef: z.string().nullable().optional(),
|
||||
homeBoardRef: z.string().nullable().optional(),
|
||||
mobileHomeBoardRef: z.string().nullable().optional(),
|
||||
permissions: z.array(z.string()),
|
||||
boardPermissions: z.array(
|
||||
z.object({ boardRef: z.string(), permission: z.string() }),
|
||||
),
|
||||
integrationPermissions: z.array(
|
||||
z.object({ integrationRef: z.string(), permission: z.string() }),
|
||||
),
|
||||
memberRefs: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const homarrConfigBundleSchema = z.object({
|
||||
version: z.literal("2.0"),
|
||||
type: z.literal("full-config"),
|
||||
exportedAt: z.string(),
|
||||
homarrVersion: z.string(),
|
||||
encryptionKey: z.string(),
|
||||
boards: z.array(bundleBoardSchema),
|
||||
apps: z.array(bundleAppSchema),
|
||||
integrations: z.array(configBundleIntegrationSchema),
|
||||
serverSettings: z.record(z.string(), z.unknown()),
|
||||
searchEngines: z.array(configBundleSearchEngineSchema),
|
||||
groups: z.array(configBundleGroupSchema),
|
||||
users: z.array(configBundleUserSchema).optional(),
|
||||
});
|
||||
|
||||
export type HomarrConfigBundle = z.infer<typeof homarrConfigBundleSchema>;
|
||||
export type ConfigBundleIntegration = z.infer<typeof configBundleIntegrationSchema>;
|
||||
export type ConfigBundleGroup = z.infer<typeof configBundleGroupSchema>;
|
||||
export type ConfigBundleSearchEngine = z.infer<typeof configBundleSearchEngineSchema>;
|
||||
export type ConfigBundleUser = z.infer<typeof configBundleUserSchema>;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { BundleCompatibility } from "./config-bundle-compat";
|
||||
|
||||
export const CONFIG_ENTITY_KEYS = [
|
||||
"boards",
|
||||
"apps",
|
||||
"integrations",
|
||||
"secrets",
|
||||
"widgets",
|
||||
"sections",
|
||||
"layouts",
|
||||
"searchEngines",
|
||||
"groups",
|
||||
"users",
|
||||
"serverSettings",
|
||||
] as const;
|
||||
|
||||
export type ConfigEntityKey = (typeof CONFIG_ENTITY_KEYS)[number];
|
||||
|
||||
export type ConfigEntityCounts = Record<ConfigEntityKey, number>;
|
||||
|
||||
export const emptyCounts = (): ConfigEntityCounts =>
|
||||
Object.fromEntries(CONFIG_ENTITY_KEYS.map((key) => [key, 0])) as ConfigEntityCounts;
|
||||
|
||||
export type ConfigExportPreview = ConfigEntityCounts;
|
||||
|
||||
export type ConfigImportPreview = {
|
||||
compatibility: BundleCompatibility;
|
||||
meta: {
|
||||
exportedAt: string;
|
||||
homarrVersion: string;
|
||||
bundleVersion: string;
|
||||
};
|
||||
toCreate: ConfigEntityCounts;
|
||||
toReuse: {
|
||||
apps: number;
|
||||
integrations: number;
|
||||
users: number;
|
||||
};
|
||||
toSkip: {
|
||||
boards: number;
|
||||
groups: number;
|
||||
searchEngines: number;
|
||||
};
|
||||
toUpdate: {
|
||||
serverSettings: number;
|
||||
};
|
||||
warnings: string[];
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type { boards } from "@homarr/db/schema";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes, emptySuperJSON } from "@homarr/definitions";
|
||||
|
||||
import type { HomarrBundleBoard } from "./schema";
|
||||
|
||||
export const parseStoredValue = <T>(value: string | null | undefined, fallback: T): T => {
|
||||
if (!value || value === emptySuperJSON) {
|
||||
return fallback;
|
||||
}
|
||||
return SuperJSON.parse(value) as T;
|
||||
};
|
||||
|
||||
export const stringifyForDb = (value: unknown): string => {
|
||||
return SuperJSON.stringify(value);
|
||||
};
|
||||
|
||||
export const mustGet = <T>(map: Map<string, T>, key: string): T => {
|
||||
const value = map.get(key);
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing map entry for key "${key}"`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const replaceMapValues = (value: unknown, map: Map<string, string>): unknown => {
|
||||
if (typeof value === "string") {
|
||||
return map.get(value) ?? value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => replaceMapValues(entry, map));
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, replaceMapValues(entry, map)]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const replaceAppIdsInValue = replaceMapValues;
|
||||
export const replaceAppRefsInValue = replaceMapValues;
|
||||
|
||||
export const buildBoardInsertRow = (
|
||||
boardId: string,
|
||||
name: string,
|
||||
creatorId: string | null,
|
||||
settings: HomarrBundleBoard["settings"],
|
||||
): InferInsertModel<typeof boards> => ({
|
||||
id: boardId,
|
||||
name,
|
||||
creatorId,
|
||||
isPublic: settings.isPublic ?? false,
|
||||
pageTitle: settings.pageTitle ?? null,
|
||||
metaTitle: settings.metaTitle ?? null,
|
||||
logoImageUrl: settings.logoImageUrl ?? null,
|
||||
faviconImageUrl: settings.faviconImageUrl ?? null,
|
||||
backgroundImageUrl: settings.backgroundImageUrl ?? null,
|
||||
backgroundImageAttachment: settings.backgroundImageAttachment ?? backgroundImageAttachments.defaultValue,
|
||||
backgroundImageRepeat: settings.backgroundImageRepeat ?? backgroundImageRepeats.defaultValue,
|
||||
backgroundImageSize: settings.backgroundImageSize ?? backgroundImageSizes.defaultValue,
|
||||
primaryColor: settings.primaryColor,
|
||||
secondaryColor: settings.secondaryColor,
|
||||
opacity: settings.opacity,
|
||||
customCss: settings.customCss ?? null,
|
||||
iconColor: settings.iconColor ?? null,
|
||||
itemRadius: settings.itemRadius,
|
||||
disableStatus: settings.disableStatus ?? false,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"dependencies": {
|
||||
"@drizzle-team/brocli": "catalog:",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/board-portability": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { command, string } from "@drizzle-team/brocli";
|
||||
|
||||
import { exportBoardBundleAsync, bundleFilenameForBoard } from "@homarr/board-portability";
|
||||
import { db } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
import { eq } from "@homarr/db";
|
||||
|
||||
export const boardsExport = command({
|
||||
name: "export",
|
||||
desc: "Export a board as a portable JSON bundle",
|
||||
options: {
|
||||
name: string("name").desc("Name of the board to export").required(),
|
||||
output: string("output").alias("o").desc("Output file path (defaults to homarr-board-<name>.json)"),
|
||||
},
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
handler: async (opts) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.name, opts.name),
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
console.error(`Board "${opts.name}" not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await exportBoardBundleAsync(db, board.id, "cli");
|
||||
|
||||
if (!result) {
|
||||
console.error(`Failed to export board "${opts.name}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outputPath = resolve(opts.output ?? bundleFilenameForBoard(board.name));
|
||||
writeFileSync(outputPath, result.content, "utf-8");
|
||||
console.log(`Board exported to ${outputPath}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { command } from "@drizzle-team/brocli";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
export const boardsList = command({
|
||||
name: "list",
|
||||
desc: "List all boards (id, name, public)",
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
handler: async () => {
|
||||
const all = await db.query.boards.findMany();
|
||||
if (all.length === 0) {
|
||||
console.log("No boards found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("ID\t\t\t\tName\t\tPublic");
|
||||
for (const board of all) {
|
||||
console.log(`${board.id}\t${board.name}\t\t${board.isPublic ? "yes" : "no"}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { command } from "@drizzle-team/brocli";
|
||||
|
||||
import { boardsExport } from "./boards-export";
|
||||
import { boardsList } from "./boards-list";
|
||||
|
||||
export const boardsRoot = command({
|
||||
name: "boards",
|
||||
desc: "Group of commands to manage boards",
|
||||
subcommands: [boardsList, boardsExport],
|
||||
});
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user