Compare commits

..
Author SHA1 Message Date
Thomas Camlong f6a598b774 feat: config portability with generic entity resolver framework
Full configuration export/import system for Homarr with excellent DX:
- Export/import boards, apps, integrations (with re-encrypted secrets),
  users (with bcrypt hashes), groups (with permissions and memberships),
  search engines, and server settings
- Homepage services.yaml import with widget-type mapping
- Generic resolveEntities() utility eliminates per-entity boilerplate
- Declarative entity-fields.ts: adding a new user preference requires
  only one line change (no export/import/schema edits needed)
- User schema uses .catchall(z.unknown()) for forward-compatible prefs
- groupByKey() utility replaces manual Map-building
- CONFIG_ENTITY_KEYS as single source of truth for entity metadata
- Reuse existing entities (apps by name+icon+href, integrations by
  kind+name+url, users by email, groups by name, boards by name)
- Skip duplicates with warnings rather than failing
- Onboarding flow skips user creation step when config includes users
- Reuse existing admin group during onboarding after config import
2026-06-06 16:22:59 +02:00
Thomas Camlong 1f7b7f0516 Merge branch 'dev' into feat/config-portability
# Conflicts:
#	apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx
#	apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx
#	packages/api/src/router/docker/docker-router.ts
#	packages/api/src/router/test/docker/docker-router.spec.ts
#	packages/db/migrations/mysql/meta/0039_snapshot.json
#	packages/db/migrations/mysql/meta/_journal.json
#	packages/db/migrations/postgresql/meta/0005_snapshot.json
#	packages/db/migrations/postgresql/meta/_journal.json
#	packages/db/migrations/sqlite/meta/0037_snapshot.json
#	packages/db/migrations/sqlite/meta/_journal.json
2026-06-06 09:32:41 +02:00
homarr-renovate[bot] 572d5fb5ec chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.15 (#5877)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 07:01:54 +00:00
Thomasandhomarr-releases[bot] 91a1858743 chore: merge main into dev
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-06 07:47:59 +02:00
homarr-renovate[bot] f00d3627c9 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.3 (#5873)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-05 18:04:10 +00:00
homarr-releases[bot] 7760e01ccb chore: update bug report template (#5821)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-05 17:47:57 +00:00
Thomas Camlong efd37dca93 fix(db): log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding
Warn on startup seed when mock integration env flag is set, matching demo mode visibility.
2026-06-05 18:43:33 +02:00
Thomas Camlong 796c679140 chore: merge main into dev 2026-06-05 18:42:31 +02:00
homarr-update-contributors[bot] 76fd4c5fb1 chore: update contributors (#5871) 2026-06-05 18:08:47 +02:00
Thomas Camlong 68f24e34d5 fix: disable onboarding tours when demo mode is enabled
Return completed tour status in demo mode so board and manage tours do not start for demo users.
2026-06-05 18:02:08 +02:00
homarr-update-contributors[bot] 22d7ebab50 chore: update integrations list readme (#5872)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-06-05 15:45:46 +00:00
Thomas Camlong 4f5f3e14a1 fix: sync docs sitemap and remove stale coolify ts-expect-error 2026-06-05 17:39:03 +02:00
Thomasandcopilot-swe-agent[bot] dbf021e04a feat(docker): add container logs modal and stats columns (#4807)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-05 16:57:26 +02:00
CopilotandThomas cc24f070db fix: redirect to login after credentials logout instead of crashing on current page (#5819)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-06-05 16:56:24 +02:00
Thomas 0b663a7fc6 feat(onboarding): guided management and board tours (#5756) 2026-06-05 16:54:50 +02:00
Afonso Jorge Ramos 0dc2558faf feat: add PeaNUT integration and UPS monitoring widget (#5862) 2026-06-05 16:51:09 +02:00
Thomas dd7a4908a3 feat(onboarding): enhance integration setup with Docker discovery and URL templating (#5607) 2026-06-05 16:50:35 +02:00
Thomas 0a6467979a feat: add comprehensive demo mode with mock integrations (#5870) 2026-06-05 16:36:45 +02:00
ThomasandMeier Lukas 25d964a197 refactor(spotlight): registry-driven CMDK preferences (#5817)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-06-05 16:26:48 +02:00
Thomas 3d3e3059a9 feat: media request search as first-class spotlight mode (#5823) 2026-06-05 16:09:14 +02:00
homarr-crowdin[bot]andMeier Lukas 9ae8cb2768 chore(lang): updated translations from crowdin (#5863)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-06-05 13:48:42 +02:00
Thomas 99346f288b feat(widgets): add in-place app editing in widget edit modal (#5809) 2026-06-05 12:31:50 +02:00
Thomas Camlong fb8721bd4e feat: add config portability — full export/import, homepage import, docker label discovery
Add a dedicated Import/Export tool page with full configuration export/import
(v2.0 bundle with encrypted secrets), Homepage services.yaml import with env
variable resolution, and manual Docker label discovery (moved from cron job).

Support importing Homarr JSON during onboarding alongside legacy zip import.

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

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

See docs/config-portability.md for full changelog.
2026-06-04 10:56:42 +02:00
Releases Homarr ff1e4c1381 chore(release): 1.63.0 [skip ci] 2026-05-29 21:07:15 +00:00
homarr-releases[bot] 21bb20ce0c chore(release): automatic release v1.63.0 2026-05-29 21:06:26 +00:00
Releases Homarr 92b75cb924 chore(release): 1.62.0 [skip ci] 2026-05-22 20:48:36 +00:00
Meier Lukas 1ea008b71c chore(release): automatic release v1.62.0 2026-05-22 22:47:05 +02:00
247 changed files with 16107 additions and 1640 deletions

No files matched your search

+8
View File
@@ -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
+1
View File
@@ -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
+4
View File
@@ -72,3 +72,7 @@ en.d.json.ts
# personal agent config
AGENTS.md
.opencode
# local docker dev
.docker-data/
.docker-cache/
-3
View File
@@ -1,3 +0,0 @@
[tools]
node = "24.16.0"
pnpm = "10.33.4"
+64
View File
@@ -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
+1 -2
View File
@@ -22,6 +22,7 @@
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
@@ -57,7 +58,6 @@
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-persist-client-core": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
@@ -65,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:",
@@ -1,49 +0,0 @@
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
import { parse, stringify } from "superjson";
import { fetchApi } from "@homarr/api/client";
import {
getActiveQueryCacheBoardId,
isPersistableWidgetQueryKey,
queryCacheStoragePrefix,
} from "@homarr/api/query-cache";
const queryCacheStorage = {
async getItem(_key) {
return null;
},
async setItem(key, value) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
} catch {
// Query persistence is best-effort; failed writes should not affect widget rendering.
}
},
async removeItem(key) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
} catch {
// Query persistence is best-effort; failed removals should not affect widget rendering.
}
},
} satisfies AsyncStorage<string>;
export const createWidgetQueryPersister = () =>
experimental_createQueryPersister<string>({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
maxAge: Number.POSITIVE_INFINITY,
prefix: queryCacheStoragePrefix,
refetchOnRestore: true,
serialize: (query) => stringify(query),
deserialize: (value) => parse<PersistedQuery>(value),
filters: {
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
},
});
@@ -17,13 +17,13 @@ import {
import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { createWidgetQueryPersister } from "./query-cache-persister";
import { showWarningNotification } from "@homarr/notifications";
const getWebSocketProtocol = () => {
// window is not defined on server side
@@ -52,14 +52,26 @@ const wsClient = createWSClient({
});
export function TRPCReactProvider(props: PropsWithChildren) {
const [queryPersister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
persister: queryPersister.persisterFn,
staleTime: queryCacheDefaultStaleTimeMs,
staleTime: 5 * 1000,
},
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
},
},
}),
@@ -1,11 +1,14 @@
import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
@@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) {
{t("subtitle")}
</Text>
</Stack>
{appEnv.DEMO_MODE && (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Alert>
)}
<Card w={64 * 6} maw="90vw">
<LoginForm
providers={env.AUTH_PROVIDERS}
@@ -1,14 +1,10 @@
import { cache } from "react";
import type { Metadata } from "next";
import { Suspense } from "react";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import type { DehydratedState } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { parse } from "superjson";
import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client";
@@ -18,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { getAllQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -39,13 +34,10 @@ interface Props<TParams extends Params> {
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
getInitialBoardAsync: getInitialBoard,
}: Props<TParams>) => {
const getCachedBoard = cache((_paramsKey: string, params: TParams) => getInitialBoard(params));
const getBoardForParams = (params: TParams) => getCachedBoard(JSON.stringify(params), params);
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getBoardForParams,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
// eslint-disable-next-line no-restricted-syntax
@@ -53,7 +45,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const resolvedParams = await params;
const queryClient = getQueryClient();
const [board, session] = await Promise.all([getBoardForParams(resolvedParams), auth()]);
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -80,14 +72,9 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
),
]);
const userId = session?.user.id ?? "anonymous";
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
@@ -95,7 +82,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try {
const board = await getBoardForParams(await params);
const board = await getInitialBoard(await params);
const t = await getI18n();
return {
@@ -111,6 +98,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
} catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return {};
}
@@ -120,37 +108,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
};
interface PersistedEntry {
state: { data: unknown; dataUpdatedAt: number; status: string };
queryKey: unknown[];
queryHash: string;
}
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
const entries = await getAllQueryCacheAsync(userId, boardId);
const queries: DehydratedState["queries"] = [];
for (const serialized of Object.values(entries)) {
try {
const persisted = parse<PersistedEntry>(serialized);
if (persisted.state.status !== "success") continue;
queries.push({
state: persisted.state as DehydratedState["queries"][number]["state"],
queryKey: persisted.queryKey,
queryHash: persisted.queryHash,
});
} catch {
// skip malformed entries
}
}
if (queries.length === 0) return null;
return <HydrationBoundary state={{ mutations: [], queries }} />;
} catch {
return null;
}
}
@@ -45,8 +45,9 @@ export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
if (!hasChangeAccess) {
if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />;
}
@@ -54,13 +55,15 @@ export const BoardContentHeaderActions = () => {
<>
{isEditMode && <AddMenu />}
<EditModeMenu />
<EditModeMenu demoMode={demoMode ?? false} />
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
{!demoMode && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
)}
<SelectBoardsMenu />
</>
@@ -155,7 +158,7 @@ const AddMenu = () => {
);
};
const EditModeMenu = () => {
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -178,10 +181,18 @@ const EditModeMenu = () => {
},
});
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board);
if (isEditMode) {
if (demoMode) return discardDemoChanges();
return saveBoard(board);
}
open();
}, [board, isEditMode, saveBoard, open]);
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
@@ -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);
@@ -78,10 +78,7 @@ export const InitIntegrations = () => {
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
const totalDetected = dockerIntegrations.length + dockerApps.length;
const detectedKinds = useMemo(
() => new Set(dockerIntegrations.map((i) => i.kind)),
[dockerIntegrations],
);
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
const [appsExpanded, setAppsExpanded] = useState(false);
@@ -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
View File
@@ -26,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -2,14 +2,14 @@
import { useCallback } from "react";
import { Menu } from "@mantine/core";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDeviceMobile, IconDownload, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { DuplicateBoardModal, ExportBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -28,7 +28,10 @@ interface BoardCardMenuDropdownProps {
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action");
const t = useScopedI18n("management.page.board.action" as never) as unknown as (
key: string,
values?: Record<string, unknown>,
) => string;
const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
@@ -36,6 +39,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const { openModal: openExportModal } = useModalAction(ExportBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
@@ -87,6 +91,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
});
}, [board.id, board.name, openDuplicateModal]);
const handleExportBoard = useCallback(() => {
openExportModal({
board: {
id: board.id,
name: board.name,
},
});
}, [board.id, board.name, openExportModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
@@ -95,6 +108,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
{t("setMobileHomeBoard.label")}
</Menu.Item>
<Menu.Item onClick={handleExportBoard} leftSection={<IconDownload {...iconProps} />}>
{t("export.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n();
const t = useI18n() as unknown as (key: string) => string;
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
@@ -10,6 +10,7 @@ import {
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDatabaseExport,
IconDirectionsFilled,
IconGitFork,
IconHelpSquareRoundedFilled,
@@ -154,6 +155,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
icon: IconDatabaseExport,
href: "/manage/tools/import-export",
hidden: !session?.user.permissions.includes("admin"),
},
],
},
{
@@ -0,0 +1,58 @@
"use client";
import { NumberInput, Switch, TextInput } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["docker"] }) => {
const tDocker = useScopedI18n("management.page.settings.section.docker" as never) as unknown as (
key: string,
) => string;
return (
<CommonSettingsForm settingKey="docker" defaultValues={defaultValues}>
{(form) => (
<>
<TextInput
{...form.getInputProps("targetBoardName")}
value={form.getInputProps("targetBoardName").value ?? ""}
label={tDocker("targetBoardName.label")}
description={tDocker("targetBoardName.description")}
/>
<Switch
{...form.getInputProps("readHomepageLabels", { type: "checkbox" })}
label={tDocker("readHomepageLabels.label")}
description={tDocker("readHomepageLabels.description")}
/>
<Switch
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
label={tDocker("createIntegrations.label")}
description={tDocker("createIntegrations.description")}
/>
<Switch
{...form.getInputProps("pruneRemoved", { type: "checkbox" })}
label={tDocker("pruneRemoved.label")}
description={tDocker("pruneRemoved.description")}
/>
<NumberInput
{...form.getInputProps("defaultItemWidth")}
label={tDocker("defaultItemWidth.label")}
description={tDocker("defaultItemWidth.description")}
min={1}
max={10}
/>
<NumberInput
{...form.getInputProps("defaultItemHeight")}
label={tDocker("defaultItemHeight.label")}
description={tDocker("defaultItemHeight.description")}
min={1}
max={10}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -5,12 +5,15 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { env } from "@homarr/docker/env";
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { DockerSettingsForm } from "./_components/docker-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
import { UserSettingsForm } from "./_components/user-settings-form";
@@ -59,6 +62,12 @@ export default async function SettingsPage() {
<Title order={2}>{tSettings("section.culture.title")}</Title>
<CultureSettingsForm defaultValues={serverSettings.culture} />
</Stack>
{env.ENABLE_DOCKER && (
<Stack>
<Title order={2}>{(tSettings as unknown as (key: string) => string)("section.docker.title")}</Title>
<DockerSettingsForm defaultValues={serverSettings.docker} />
</Stack>
)}
</Stack>
</>
);
@@ -2,7 +2,6 @@
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
@@ -45,34 +44,34 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
return () => undefined;
}
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
const fitAddon = new FitAddon();
const terminal = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 14,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
isTerminalReadyRef.current = true;
const terminal = terminalRef.current;
pendingLogsRef.current.forEach((data) => terminal.write(data));
pendingLogsRef.current = [];
terminal.refresh(0, terminal.rows - 1);
setTimeout(() => {
const fitAddon = new FitAddon();
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
const fitTimeout = setTimeout(() => fitAddon.fit());
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(ref.current);
return () => {
clearTimeout(fitTimeout);
resizeObserver.disconnect();
isTerminalReadyRef.current = false;
pendingLogsRef.current = [];
canvasAddon.dispose();
terminalRef.current?.dispose();
fitAddon.dispose();
terminal.dispose();
terminalRef.current = null;
};
}, [writeToTerminal]);
@@ -0,0 +1,124 @@
"use client";
import { Avatar, Badge, Button, Card, Group, Stack, Table, Text, Title } from "@mantine/core";
import { IconRefresh, IconSearch } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export const DockerDiscoveryPanel = () => {
const tDiscovery = useScopedI18n("docker.discovery" as never) as unknown as (
key: string,
values?: Record<string, unknown>,
) => string;
const { data: preview, refetch, isLoading: isLoadingPreview, isFetched } =
clientApi.docker.getDiscoveredPreview.useQuery(undefined, {
enabled: false,
});
const { mutate: syncDiscovery, isPending: isSyncing } = clientApi.docker.syncDiscovery.useMutation({
onSuccess(result) {
showSuccessNotification({
title: tDiscovery("sync.notification.success.title"),
message: tDiscovery("sync.notification.success.message", {
created: result.created,
updated: result.updated,
}),
});
void refetch();
},
onError() {
showErrorNotification({
title: tDiscovery("sync.notification.error.title"),
message: tDiscovery("sync.notification.error.message"),
});
},
});
const services = preview ?? [];
return (
<Card withBorder>
<Stack>
<Group justify="space-between">
<Title order={3}>{tDiscovery("title")}</Title>
<Group gap="xs">
<Button
variant="light"
leftSection={<IconSearch size={16} />}
loading={isLoadingPreview}
onClick={() => refetch()}
>
{tDiscovery("preview.label")}
</Button>
<Button
leftSection={<IconRefresh size={16} />}
loading={isSyncing}
onClick={() => syncDiscovery()}
disabled={services.length === 0}
>
{tDiscovery("sync.label")}
</Button>
</Group>
</Group>
{!isFetched ? (
<Text c="dimmed" ta="center" py="xl">
{tDiscovery("preview.idle")}
</Text>
) : services.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{tDiscovery("preview.empty")}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{tDiscovery("field.name")}</Table.Th>
<Table.Th>{tDiscovery("field.group")}</Table.Th>
<Table.Th>{tDiscovery("field.href")}</Table.Th>
<Table.Th>{tDiscovery("field.host")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{services.map((service) => (
<Table.Tr key={`${service.host}/${service.containerId}`}>
<Table.Td>
<Group gap="xs">
{service.icon && (
<Avatar size="sm" radius="sm" src={service.icon}>
{service.name.charAt(0)}
</Avatar>
)}
<Text size="sm">{service.name}</Text>
{service.integrationKind && (
<Badge size="xs" variant="light">
{service.integrationKind}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{service.group}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={1}>
{service.href}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{service.host}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
);
};
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { Alert, Button, Card, Group, List, Loader, Popover, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconDatabaseExport } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigSummaryList } from "~/components/config-import/config-summary-list";
const exportWarningPointKeys = [
"management.page.importExport.export.warning.message",
"management.page.importExport.export.warning.storage",
"management.page.importExport.export.warning.sharing",
] as const;
export const ExportConfigCard = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const { mutateAsync, isPending } = clientApi.config.exportFull.useMutation();
const [popoverOpened, setPopoverOpened] = useState(false);
const { data: preview, isLoading: previewLoading } = clientApi.config.previewExport.useQuery(undefined, {
enabled: popoverOpened,
});
const closePopover = () => setPopoverOpened(false);
const runExport = async () => {
await mutateAsync(undefined, {
onSuccess({ content, filename }) {
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
showSuccessNotification({
title: t("management.page.importExport.export.notification.success.title"),
message: t("management.page.importExport.export.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.export.notification.error.title"),
message: t("management.page.importExport.export.notification.error.message"),
});
},
});
};
const handleConfirmExport = () => {
closePopover();
void runExport();
};
return (
<Card withBorder>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.export.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.export.description")}
</Text>
</Stack>
<Popover
opened={popoverOpened}
onChange={setPopoverOpened}
width={420}
position="bottom-end"
withArrow
shadow="md"
>
<Popover.Target>
<Group justify="flex-end">
<Button
leftSection={<IconDatabaseExport size="1rem" />}
loading={isPending}
disabled={isPending}
onClick={() => setPopoverOpened(true)}
>
{t("management.page.importExport.export.action")}
</Button>
</Group>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="md">
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size="1.25rem" stroke={1.5} />}
title={t("management.page.importExport.export.warning.title")}
>
<List size="sm" spacing="xs">
{exportWarningPointKeys.map((key) => (
<List.Item key={key}>{t(key)}</List.Item>
))}
</List>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.summary.title")}
</Text>
{previewLoading && (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
{t("management.page.importExport.preview.exportLoading")}
</Text>
</Group>
)}
{preview && <ConfigSummaryList counts={preview} t={t} />}
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closePopover}>
{t("common.action.cancel")}
</Button>
<Button
color="red.9"
onClick={handleConfirmExport}
disabled={previewLoading}
loading={isPending}
>
{t("common.action.continue")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
</Stack>
</Card>
);
};
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, Button, Card, FileInput, Group, List, Stack, Text, Title } from "@mantine/core";
import { IconCheck, IconFileUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
export const ImportConfigCard = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const [file, setFile] = useState<File | null>(null);
const [fileContent, setFileContent] = useState("");
const [importResult, setImportResult] = useState<{
boards: number;
apps: number;
integrations: number;
groups: number;
searchEngines: number;
warnings: string[];
} | null>(null);
const {
mutate: runPreview,
data: preview,
isPending: previewLoading,
reset: resetPreview,
} = clientApi.config.previewImport.useMutation();
const { mutateAsync, isPending } = clientApi.config.importFull.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage");
},
});
useEffect(() => {
if (!fileContent) {
resetPreview();
return;
}
runPreview({ content: fileContent });
}, [fileContent, runPreview, resetPreview]);
const importAllowed = preview?.compatibility.status === "compatible";
const handleImport = async () => {
if (!file || !importAllowed) {
return;
}
const content = await file.text();
await mutateAsync(
{ content },
{
onSuccess(result) {
setImportResult(result);
showSuccessNotification({
title: t("management.page.importExport.importConfig.notification.success.title"),
message: t("management.page.importExport.importConfig.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.importConfig.notification.error.title"),
message: t("management.page.importExport.importConfig.notification.error.message"),
});
},
},
);
};
return (
<Card withBorder>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.importConfig.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.importConfig.description")}
</Text>
</Stack>
<FileInput
rightSection={<IconFileUpload />}
accept="application/json"
value={file}
onChange={(newFile) => {
setFile(newFile);
setImportResult(null);
setFileContent("");
if (!newFile) {
return;
}
void newFile.text().then((content) => {
setFileContent(content);
});
}}
label={t("management.page.importExport.importConfig.fileLabel")}
/>
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
{importResult && (
<Alert color="green" icon={<IconCheck />}>
<List size="sm">
<List.Item>{t("management.page.importExport.importConfig.result.boardsImported", { count: importResult.boards })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.appsCreated", { count: importResult.apps })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.integrationsCreated", { count: importResult.integrations })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.groupsCreated", { count: importResult.groups })}</List.Item>
</List>
</Alert>
)}
<Group justify="end">
<Button loading={isPending} disabled={!file || !importAllowed || previewLoading} onClick={() => void handleImport()}>
{t("common.action.import")}
</Button>
</Group>
</Stack>
</Card>
);
};
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { Button, Card, Code, FileInput, Group, Stack, Switch, Text, TextInput, Title } from "@mantine/core";
import { IconFileUpload } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import {
extractHomepageEnvVariables,
parseServicesYaml,
replaceHomepageEnvVariables,
} from "@homarr/board-portability/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { boardNameSchema } from "@homarr/validation/board";
export const ImportHomepageCard = () => {
const t = useI18n() as unknown as (key: string) => string;
const [fileValid, setFileValid] = useState(true);
const [rawContent, setRawContent] = useState("");
const [envVars, setEnvVars] = useState<string[]>([]);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const form = useZodForm(
z.object({
file: z.file(),
boardName: boardNameSchema,
createIntegrations: z.boolean(),
}),
{
mode: "controlled",
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
boardName: "",
createIntegrations: true,
},
onValuesChange(values, previous) {
void (async () => {
if (values.file === previous.file) return;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!values.file) return;
const content = await values.file.text();
setRawContent(content);
const vars = extractHomepageEnvVariables(content);
setEnvVars(vars);
setEnvValues(Object.fromEntries(vars.map((v: string) => [v, ""])));
const result = parseServicesYaml(content);
if (!result.success) {
setFileValid(false);
return;
}
setFileValid(true);
const suggestedName =
result.services[0]?.group.replaceAll(/[^A-Za-z0-9-_]/g, "-").replaceAll(/-+/g, "-") ?? "homepage";
if (form.values.boardName.trim().length === 0) {
form.setFieldValue("boardName", suggestedName.slice(0, 255));
}
})();
},
},
);
const { mutateAsync, isPending } = clientApi.board.importHomepageServices.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/boards");
},
});
const allEnvVarsFilled = envVars.length === 0 || envVars.every((v) => (envValues[v] ?? "").trim().length > 0);
return (
<Card withBorder>
<form
onSubmit={form.onSubmit((values) => {
if (!fileValid || !allEnvVarsFilled) return;
void (async () => {
const resolvedContent =
envVars.length > 0 ? replaceHomepageEnvVariables(rawContent, envValues) : rawContent;
await mutateAsync(
{
content: resolvedContent,
boardName: values.boardName,
createIntegrations: values.createIntegrations,
},
{
onSuccess() {
showSuccessNotification({
title: t("management.page.importExport.importHomepage.notification.success.title"),
message: t("management.page.importExport.importHomepage.notification.success.message"),
});
form.reset();
setRawContent("");
setEnvVars([]);
setEnvValues({});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.importHomepage.notification.error.title"),
message: t("management.page.importExport.importHomepage.notification.error.message"),
});
},
},
);
})();
})}
>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.importHomepage.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.importHomepage.description")}
</Text>
</Stack>
<FileInput
rightSection={<IconFileUpload />}
withAsterisk
accept=".yaml,.yml"
{...form.getInputProps("file")}
error={
(form.getInputProps("file").error as string | undefined) ??
(!fileValid && form.isDirty("file") ? t("management.page.importExport.importHomepage.invalidFile") : undefined)
}
type="button"
label={t("management.page.importExport.importHomepage.fileLabel")}
/>
{envVars.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.importHomepage.envVariables.label")}
</Text>
<Text size="xs" c="dimmed">
{t("management.page.importExport.importHomepage.envVariables.description")}
</Text>
{envVars.map((varName) => (
<TextInput
key={varName}
label={<Code>{`{{${varName}}}`}</Code>}
placeholder={varName.replace("HOMEPAGE_VAR_", "").toLowerCase().replaceAll("_", ".")}
value={envValues[varName] ?? ""}
onChange={(event) => {
setEnvValues((prev) => ({ ...prev, [varName]: event.target.value }));
}}
withAsterisk
/>
))}
</Stack>
)}
<TextInput
withAsterisk
label={t("management.page.importExport.importHomepage.boardNameLabel")}
{...form.getInputProps("boardName")}
/>
<Switch
label={t("management.page.importExport.importHomepage.createIntegrations")}
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending} disabled={!allEnvVarsFilled}>
{t("common.action.import")}
</Button>
</Group>
</Stack>
</form>
</Card>
);
};
@@ -0,0 +1,34 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { DockerDiscoveryPanel } from "./docker-discovery-panel";
import { ExportConfigCard } from "./export-config-card";
import { ImportConfigCard } from "./import-config-card";
import { ImportHomepageCard } from "./import-homepage-card";
export default async function ImportExportPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const t = await getScopedI18n("management.page.importExport" as never) as unknown as (key: string) => string;
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{t("title")}</Title>
<ExportConfigCard />
<ImportConfigCard />
<ImportHomepageCard />
{env.ENABLE_DOCKER && <DockerDiscoveryPanel />}
</Stack>
</>
);
}
@@ -2,7 +2,6 @@
import { useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
@@ -15,8 +14,8 @@ export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels, fontSize } = useLogContext();
const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
clientApi.log.subscribe.useSubscription(
{
@@ -38,28 +37,31 @@ export const TerminalComponent = () => {
return () => undefined;
}
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
const fitAddon = new FitAddon();
const terminal = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
setTimeout(() => {
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
const fitTerminal = () => fitAddon.fit();
const fitTimeout = setTimeout(fitTerminal);
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(ref.current);
return () => {
canvasAddon.dispose();
terminalRef.current?.dispose();
clearTimeout(fitTimeout);
resizeObserver.disconnect();
fitAddon.dispose();
terminal.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
};
}, []);
@@ -1,85 +0,0 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/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 { userChangeHomeBoardsSchema } from "@homarr/validation/user";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
interface ChangeHomeBoardFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
}
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
homeBoardId: variables.homeBoardId,
mobileHomeBoardId: variables.mobileHomeBoardId,
});
showSuccessNotification({
message: t("user.action.changeHomeBoard.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeHomeBoard.notification.error.message"),
});
},
});
const form = useZodForm(userChangeHomeBoardsSchema, {
initialValues: {
homeBoardId: user.homeBoardId,
mobileHomeBoardId: user.mobileHomeBoardId,
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<BoardSelect
label={t("management.page.user.setting.general.item.board.type.general")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={t("management.page.user.setting.general.item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("mobileHomeBoardId")}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userChangeHomeBoardsSchema>;
@@ -1,84 +0,0 @@
"use client";
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/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 { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
interface ChangeSearchPreferencesFormProps {
user: RouterOutputs["user"]["getById"];
searchEnginesData: { value: string; label: string }[];
}
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
openInNewTab: variables.openInNewTab,
ddgBangsEnabled: variables.ddgBangsEnabled,
});
showSuccessNotification({
message: t("user.action.changeSearchPreferences.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeSearchPreferences.notification.error.message"),
});
},
});
const form = useZodForm(userChangeSearchPreferencesSchema, {
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
ddgBangsEnabled: user.ddgBangs,
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("user.field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={t("user.field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userChangeSearchPreferencesSchema>;
@@ -1,83 +0,0 @@
"use client";
import { Button, Group, Radio, Stack } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/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 { userFirstDayOfWeekSchema } from "@homarr/validation/user";
dayjs.extend(localeData);
interface FirstDayOfWeekProps {
user: RouterOutputs["user"]["getById"];
}
const weekDays = dayjs.weekdays(false);
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
});
showSuccessNotification({
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
});
},
});
const form = useZodForm(userFirstDayOfWeekSchema, {
initialValues: {
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
const inputProps = form.getInputProps("firstDayOfWeek");
const onChange = inputProps.onChange as (value: number) => void;
const value = (inputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Radio.Group {...inputProps} value={value} onChange={(nextValue: string) => onChange(parseInt(nextValue, 10))}>
<Group mt="xs">
<Radio value="1" label={weekDays[1]} />
<Radio value="6" label={weekDays[6]} />
<Radio value="0" label={weekDays[0]} />
</Group>
</Radio.Group>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userFirstDayOfWeekSchema>;
@@ -1,69 +0,0 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/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 { userPingIconsEnabledSchema } from "@homarr/validation/user";
interface PingIconsEnabledProps {
user: RouterOutputs["user"]["getById"];
}
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
pingIconsEnabled: variables.pingIconsEnabled,
});
showSuccessNotification({
message: t("user.action.changePingIconsEnabled.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changePingIconsEnabled.notification.error.message"),
});
},
});
const form = useZodForm(userPingIconsEnabledSchema, {
initialValues: {
pingIconsEnabled: user.pingIconsEnabled,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
label={t("user.field.pingIconsEnabled.label")}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof userPingIconsEnabledSchema>;
@@ -1,94 +0,0 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/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 { userEditProfileSchema } from "@homarr/validation/user";
interface UserProfileFormProps {
user: RouterOutputs["user"]["getById"];
}
export const UserProfileForm = ({ user }: UserProfileFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
async onSettled() {
await revalidatePathActionAsync("/manage/users");
},
onSuccess(_, variables) {
// Reset form initial values to reset dirty state
form.setInitialValues({
name: variables.name,
email: variables.email ?? "",
});
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("user.action.editProfile.notification.success.message"),
});
},
onError(error) {
const message =
error.data?.code === "CONFLICT"
? t("user.error.usernameTaken")
: t("user.action.editProfile.notification.error.message");
showErrorNotification({
title: t("common.notification.update.error"),
message,
});
},
});
const form = useZodForm(userEditProfileSchema.omit({ id: true }), {
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
});
// Only credentials users can edit their profile
const isProviderCredentials = user.provider === "credentials";
const handleSubmit = useCallback(
(values: FormType) => {
if (!isProviderCredentials) return;
mutate({
...values,
id: user.id,
});
},
[isProviderCredentials, mutate, user.id],
);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isProviderCredentials}
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
{isProviderCredentials && (
<Group justify="end">
<Button type="submit" disabled={!form.isDirty()} loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
)}
</Stack>
</form>
);
};
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
@@ -6,18 +6,13 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
import { ResetTours } from "./_components/_reset-tours";
interface Props {
@@ -64,7 +59,7 @@ export default async function EditUserPage(props: Props) {
const boards = await api.board.getAllBoards();
const searchEngines = await api.searchEngine.getSelectable();
const isSelf = session?.user.id === user.id;
const isCredentialsUser = user.provider === "credentials";
return (
@@ -74,51 +69,25 @@ export default async function EditUserPage(props: Props) {
{t("management.page.user.fieldsDisabledExternalProvider")}
</Alert>
)}
<Title>{tGeneral("title")}</Title>
<Group gap="xl">
<Box flex={1}>
<UserProfileForm user={user} />
<Group gap="xl" align="flex-start" wrap="wrap">
<Box flex={1} miw={{ base: "100%", md: 540 }}>
<UserGeneralSettingsForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
searchEnginesData={searchEngines}
showLanguageSelector={isSelf}
/>
</Box>
<Box w={{ base: "100%", lg: 200 }}>
<Box w={{ base: "100%", lg: 260 }}>
<UserProfileAvatarForm user={user} />
</Box>
</Group>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
)}
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board.title")}</Title>
<ChangeHomeBoardForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.search")}</Title>
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
@@ -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();
@@ -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>
);
};
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
window.location.assign(logoutUrl);
return;
}
router.refresh();
router.push("/auth/login");
},
});
}, [logoutUrl, openModal, router]);
+1
View File
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});
+20 -10
View File
@@ -19,6 +19,9 @@
<!-- Links -->
<p align="center">
<a href="https://demo.homarr.dev">
<strong>Live Demo 🎮</strong>
</a> •
<a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong>
</a> •
@@ -246,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/>
@@ -293,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/>
@@ -342,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/>
+153
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -9,7 +9,6 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./query-cache": "./src/query-cache.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
@@ -23,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",
+3 -1
View File
@@ -1,12 +1,14 @@
import { z } from "zod/v4";
import { createEnv } from "@homarr/core/infrastructure/env";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
},
});
+2 -2
View File
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
import { publicProcedure } from "../trpc";
import { isDemoMode, publicProcedure } from "../trpc";
export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER) {
if (env.ENABLE_DOCKER || isDemoMode) {
return await next();
}
throw new TRPCError({
-34
View File
@@ -1,34 +0,0 @@
import type { QueryKey } from "@tanstack/react-query";
export const queryCacheRetentionMs = 1000 * 60 * 60 * 24;
export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5;
export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
let activeQueryCacheBoardId: string | null = null;
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
activeQueryCacheBoardId = boardId;
};
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
if (!Array.isArray(first)) return null;
const parts = first.filter((part): part is string => typeof part === "string");
return parts.length > 0 ? parts : null;
};
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
return persistableQueryPaths.has(path.join("."));
};
+1 -1
View File
@@ -24,11 +24,11 @@ export const appRouter = createTRPCRouter({
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
config: lazy(() => import("./router/config").then((mod) => mod.configRouter)),
});
// export type definition of API
+117
View File
@@ -35,6 +35,12 @@ import {
getPermissionsWithParents,
widgetKinds,
} from "@homarr/definitions";
import {
exportBoardBundleAsync,
importBoardBundleAsync,
importHomepageServicesAsync,
parseBundleJson,
} from "@homarr/board-portability";
import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
@@ -43,6 +49,9 @@ import {
boardChangeVisibilitySchema,
boardCreateSchema,
boardDuplicateSchema,
boardExportBundleSchema,
boardImportBundleSchema,
boardNameSchema,
boardRenameSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
@@ -54,6 +63,7 @@ import { zodUnionFromArray } from "@homarr/validation/enums";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import packageJson from "../../../../package.json";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
@@ -1327,6 +1337,113 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
exportBundle: protectedProcedure.input(boardExportBundleSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
const result = await exportBoardBundleAsync(ctx.db, input.id, packageJson.version);
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
}
return {
filename: result.filename,
content: result.content,
};
}),
importBundle: permissionRequiredProcedure
.requiresPermission("board-create")
.input(boardImportBundleSchema)
.mutation(async ({ ctx, input }) => {
await noBoardWithSimilarNameAsync(ctx.db, input.name);
const bundle = parseBundleJson(input.content);
const existingApps = await ctx.db.query.apps.findMany();
const existingIntegrations = await ctx.db.query.integrations.findMany();
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
const integrationIdsWithAccess = hasAccessForAll
? []
: await ctx.db
.selectDistinct({
id: integrationGroupPermissions.integrationId,
})
.from(integrationGroupPermissions)
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, ctx.session.user.id))
.union(
ctx.db
.selectDistinct({ id: integrationUserPermissions.integrationId })
.from(integrationUserPermissions)
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
)
.then((result) => result.map((row) => row.id));
const report = await importBoardBundleAsync(
ctx.db,
bundle,
input.name,
ctx.session.user.id,
existingApps,
existingIntegrations,
integrationIdsWithAccess,
hasAccessForAll,
);
return {
boardId: report.boardId,
created: report.created,
warnings: report.warnings,
};
}),
importHomepageServices: permissionRequiredProcedure
.requiresPermission("board-create")
.input(
z.object({
content: z.string().min(1),
boardName: boardNameSchema,
createIntegrations: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) => {
await noBoardWithSimilarNameAsync(ctx.db, input.boardName);
try {
const report = await importHomepageServicesAsync(
ctx.db,
input.content,
input.boardName,
ctx.session.user.id,
input.createIntegrations,
);
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
homeBoardId: true,
},
});
if (!user?.homeBoardId) {
await ctx.db.update(users).set({ homeBoardId: report.boardId }).where(eq(users.id, ctx.session.user.id));
}
return {
boardId: report.boardId,
created: report.created,
warnings: report.warnings,
unmappedWidgetTypes: report.unmappedWidgetTypes,
};
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Could not import Homepage services",
});
}
}),
});
/**
+42
View File
@@ -0,0 +1,42 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import {
exportFullConfigAsync,
importFullConfigAsync,
parseAndValidateBundle,
previewExportFullConfigAsync,
previewImportFullConfigAsync,
} from "@homarr/board-portability";
import packageJson from "../../../../package.json";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const configRouter = createTRPCRouter({
previewExport: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await previewExportFullConfigAsync(ctx.db);
}),
previewImport: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
}),
exportFull: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
return await exportFullConfigAsync(ctx.db, packageJson.version);
}),
importFull: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
try {
const bundle = parseAndValidateBundle(input.content, packageJson.version);
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
}
}),
});
@@ -3,7 +3,8 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
import { getServerSettingsAsync } from "@homarr/db/queries";
import {
dockerContainersRequestHandler,
getContainerLogsAsync,
@@ -108,6 +109,32 @@ export const dockerRouter = createTRPCRouter({
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
getDiscoveredPreview: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
return await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
}),
syncDiscovery: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.mutation(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
const discovered = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
return await syncDiscoveredServicesAsync(discovered, {
targetBoardName: serverSettings.docker.targetBoardName,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
defaultItemWidth: serverSettings.docker.defaultItemWidth,
defaultItemHeight: serverSettings.docker.defaultItemHeight,
});
}),
logs: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
@@ -1,5 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { importFullConfigAsync, parseAndValidateBundle, previewImportFullConfigAsync } from "@homarr/board-portability";
import { onboarding } from "@homarr/db/schema";
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
import {
ensureValidTokenOrThrow,
@@ -7,6 +10,7 @@ import {
importInitialOldmarrInputSchema,
} from "@homarr/old-import/import";
import packageJson from "../../../../../package.json";
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
@@ -19,12 +23,7 @@ export const importRouter = createTRPCRouter({
}),
validateToken: onboardingProcedure
.requiresStep("import")
.input(
z.object({
checksum: z.string(),
token: z.string(),
}),
)
.input(z.object({ checksum: z.string(), token: z.string() }))
.mutation(({ input }) => {
try {
ensureValidTokenOrThrow(input.checksum, input.token);
@@ -40,4 +39,28 @@ export const importRouter = createTRPCRouter({
await importInitialOldmarrAsync(ctx.db, input);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
previewInitialConfigImport: onboardingProcedure
.requiresStep("import")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
}),
importInitialConfigImport: onboardingProcedure
.requiresStep("import")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
try {
const bundle = parseAndValidateBundle(input.content, packageJson.version);
await importFullConfigAsync(ctx.db, bundle, null);
const hasUsers = (bundle.users ?? []).length > 0;
if (hasUsers) {
await ctx.db.update(onboarding).set({ previousStep: "import", step: "finish" });
} else {
await nextOnboardingStepAsync(ctx.db, undefined);
}
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
}
}),
});
+2 -1
View File
@@ -1,7 +1,7 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
@@ -13,4 +13,5 @@ export const infoRouter = createTRPCRouter({
version: packageJson.version,
};
}),
isDemoMode: publicProcedure.query(() => isDemoMode),
});
-54
View File
@@ -1,54 +0,0 @@
import { z } from "zod/v4";
import { eq } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { createQueryCacheChannel } from "@homarr/redis";
import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
const queryCacheKeyInput = z.object({
boardId: z.string().min(1),
key: z.string().min(1).max(4096),
});
const queryCacheSetInput = queryCacheKeyInput.extend({
value: z.string().max(queryCacheMaxValueBytes * 2),
});
const createChannel = (input: z.infer<typeof queryCacheKeyInput>, userId: string) =>
createQueryCacheChannel({
userId,
boardId: input.boardId,
key: input.key,
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
};
export const queryCacheRouter = createTRPCRouter({
getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return await channel.getAsync();
}),
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
return {
stored: await channel.setAsync(input.value),
};
}),
removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => {
await validateBoardAccessAsync(ctx, input.boardId);
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
await channel.removeAsync();
}),
});
@@ -50,6 +50,19 @@ vi.mock("@homarr/docker/env", () => ({
ENABLE_DOCKER: true,
},
}));
vi.mock("@homarr/docker", () => ({
DockerSingleton: {
getInstances: () => [],
},
listDiscoveredContainersAsync: async () => [],
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
}));
vi.mock("@homarr/db/queries", () => ({
getServerSettingsAsync: async () => ({
docker: { targetBoardName: null, readHomepageLabels: true, pruneRemoved: false },
board: { enableStatusByDefault: true, forceDisableStatus: false },
}),
}));
const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) =>
({
@@ -73,6 +86,8 @@ const validInputs: {
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
getDiscoveredPreview: undefined,
syncDiscovery: undefined,
logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 },
};
+44 -14
View File
@@ -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
@@ -629,6 +652,13 @@ export const userRouter = createTRPCRouter({
.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,
+1
View File
@@ -31,4 +31,5 @@ export const widgetRouter = createTRPCRouter({
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
});
+51
View File
@@ -0,0 +1,51 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { UpsSummary } from "@homarr/integrations/types";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
return innerHandler.subscribe((summaries) => {
emit.next({
integrationId: integration.id,
summaries,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
+1 -3
View File
@@ -1,7 +1,5 @@
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
import { queryCacheDefaultStaleTimeMs } from "./query-cache";
/**
* Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed
@@ -60,7 +58,7 @@ export const makeQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
staleTime: 5 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
-224
View File
@@ -1,224 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import {
isPersistableWidgetQueryKey,
queryCacheDefaultStaleTimeMs,
queryCacheMaxValueBytes,
queryCacheRetentionMs,
} from "@homarr/api/query-cache";
import { queryCacheRouter } from "../router/query-cache";
const redisMock = vi.hoisted(() => {
const values = new Map<string, { value: string; expiresAt: number }>();
const calls: {
userId: string;
boardId: string;
key: string;
ttlMs: number;
maxValueBytes: number;
}[] = [];
return {
values,
calls,
createQueryCacheChannel: vi.fn(
(options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => {
calls.push(options);
const storageKey = `${options.userId}:${options.boardId}:${options.key}`;
return {
getAsync: async () => {
const item = values.get(storageKey);
if (!item) return null;
if (Date.now() > item.expiresAt) {
values.delete(storageKey);
return null;
}
return item.value;
},
setAsync: async (value: string) => {
if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) {
return false;
}
values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs });
return true;
},
removeAsync: async () => {
values.delete(storageKey);
},
};
},
),
};
});
vi.mock("@homarr/redis", () => ({
createQueryCacheChannel: redisMock.createQueryCacheChannel,
}));
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
afterEach(() => {
vi.useRealTimers();
redisMock.values.clear();
redisMock.calls.length = 0;
redisMock.createQueryCacheChannel.mockClear();
});
const createSession = (userId = createId()): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const insertUserAsync = async (db: Database, id: string) => {
await db.insert(users).values({ id });
};
describe("isPersistableWidgetQueryKey", () => {
test.each([
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
[[["app", "byId"], { type: "query" }], true],
[[["app", "byIds"], { type: "query" }], true],
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
])("matches persist filter for %j", (queryKey, expected) => {
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
});
});
describe("queryCacheRouter", () => {
test("stores, reads, and removes data for a board viewer", async () => {
const db = createDb();
const session = createSession();
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({
stored: true,
});
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await caller.removeItem({ boardId: "board-1", key: "query-key" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
test("does not touch Redis when board access is denied", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
redisMock.createQueryCacheChannel.mockClear();
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found");
expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled();
});
test("scopes anonymous public-board cache entries by anonymous user and board", async () => {
const db = createDb();
await insertUserAsync(db, "owner");
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "anonymous",
boardId: "board-1",
key: "query-key",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("scopes signed-in cache entries by user and rejects oversized values", async () => {
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
const value = "x".repeat(queryCacheMaxValueBytes + 1);
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({
stored: false,
});
expect(redisMock.calls.at(-1)).toMatchObject({
userId: "user-1",
boardId: "board-1",
ttlMs: queryCacheRetentionMs,
maxValueBytes: queryCacheMaxValueBytes,
});
});
test("expires values through the Redis TTL path", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const db = createDb();
const session = createSession("user-1");
await insertUserAsync(db, session.user.id);
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
await vi.advanceTimersByTimeAsync(queryCacheRetentionMs + 1);
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
});
});
describe("query cache stale handling", () => {
test("uses TanStack Query dataUpdatedAt and staleTime to decide when cached data refetches", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z"));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: queryCacheDefaultStaleTimeMs,
},
},
});
const queryKey = [["widget", "example"], { input: { integrationIds: ["integration-1"] }, type: "query" }];
const queryFn = vi.fn().mockResolvedValue("fresh");
queryClient.setQueryData(queryKey, "cached", {
updatedAt: Date.now() - queryCacheDefaultStaleTimeMs - 1,
});
await expect(
queryClient.fetchQuery({
queryKey,
queryFn,
}),
).resolves.toBe("fresh");
expect(queryFn).toHaveBeenCalledOnce();
});
});
+17 -7
View File
@@ -18,6 +18,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" });
@@ -87,6 +88,20 @@ export const createCallerFactory = t.createCallerFactory;
*/
export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE;
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Mutations are disabled in demo mode",
});
}
return next({ ctx });
});
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/**
* Public (unauthed) procedure
*
@@ -94,19 +109,14 @@ export const createTRPCRouter = t.router;
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = t.procedure;
export const publicProcedure = baseProcedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
@@ -121,7 +131,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
/**
* Procedure that requires a specific permission
+9
View File
@@ -0,0 +1,9 @@
export {
homarrBundleSchema,
homarrConfigBundleSchema,
type HomarrBundle,
type HomarrConfigBundle,
} from "./src/schema";
export { parseServicesYaml } from "./src/homepage/parse-services-yaml";
export { extractHomepageEnvVariables, replaceHomepageEnvVariables } from "./src/homepage/env-variables";
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./src/homepage/types";
+25
View File
@@ -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";
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@homarr/board-portability",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./index.ts",
"./client": "./client.ts",
"./homepage": "./src/homepage/index.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"superjson": "catalog:",
"yaml": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -0,0 +1,178 @@
import { describe, expect, test } from "vitest";
import type { HomarrBundle } from "../schema";
import { homarrBundleSchema } from "../schema";
import { bundleToJson, bundleFilenameForBoard } from "../export/to-json";
import { parseBundleJson } from "../import/parse-bundle";
const createMinimalBundle = (overrides: Partial<HomarrBundle> = {}): HomarrBundle => ({
version: "1.0",
exportedAt: new Date().toISOString(),
homarrVersion: "1.0.0-test",
boards: [
{
name: "Test Board",
settings: {
primaryColor: "#FA5252",
secondaryColor: "#868E96",
opacity: 100,
itemRadius: "md",
},
layouts: [
{
ref: "layout-1",
name: "Default",
columnCount: 10,
breakpoint: 0,
},
],
sections: [
{
ref: "section-1",
kind: "category",
name: "Apps",
yOffset: 0,
xOffset: 0,
},
],
items: [
{
ref: "item-1",
kind: "app",
options: { appId: "app-ref-1", openInNewTab: true },
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
integrationRefs: [],
appRef: "app-ref-1",
layouts: [
{
layoutRef: "layout-1",
sectionRef: "section-1",
x: 0,
y: 0,
w: 2,
h: 1,
},
],
},
],
},
],
apps: [
{
ref: "app-ref-1",
name: "Jellyfin",
href: "https://jellyfin.local",
iconUrl: "https://cdn.example/jellyfin.png",
description: "Media server",
pingUrl: "https://jellyfin.local/health",
},
],
integrations: [
{
ref: "int-ref-1",
kind: "jellyfin",
name: "Jellyfin",
url: "https://jellyfin.local",
secretKinds: ["apiKey"],
secrets: "REDACTED",
},
],
...overrides,
});
describe("bundle round-trip", () => {
test("serialized bundle parses back to equal object", () => {
const original = createMinimalBundle();
const json = bundleToJson(original);
const parsed = parseBundleJson(json);
expect(parsed.version).toBe(original.version);
expect(parsed.homarrVersion).toBe(original.homarrVersion);
expect(parsed.boards).toHaveLength(1);
expect(parsed.boards[0]?.name).toBe("Test Board");
expect(parsed.apps).toHaveLength(1);
expect(parsed.apps[0]?.name).toBe("Jellyfin");
expect(parsed.integrations).toHaveLength(1);
expect(parsed.integrations[0]?.secrets).toBe("REDACTED");
});
test("schema validates valid bundle", () => {
const bundle = createMinimalBundle();
const result = homarrBundleSchema.safeParse(bundle);
expect(result.success).toBe(true);
});
test("schema rejects missing boards", () => {
const invalid = { ...createMinimalBundle(), boards: [] };
const result = homarrBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("schema rejects invalid version", () => {
const invalid = { ...createMinimalBundle(), version: "2.0" };
const result = homarrBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("bundle preserves multiple items with layouts", () => {
const bundle = createMinimalBundle();
bundle.boards[0]?.items.push({
ref: "item-2",
kind: "clock",
options: { is24HourFormat: true },
advancedOptions: { title: "My Clock", customCssClasses: ["custom"], borderColor: "#000" },
integrationRefs: [],
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 2, y: 0, w: 2, h: 1 }],
});
const json = bundleToJson(bundle);
const parsed = parseBundleJson(json);
expect(parsed.boards[0]?.items).toHaveLength(2);
expect(parsed.boards[0]?.items[1]?.kind).toBe("clock");
expect(parsed.boards[0]?.items[1]?.advancedOptions.title).toBe("My Clock");
});
test("bundle preserves section layouts", () => {
const bundle = createMinimalBundle();
const firstSection = bundle.boards[0]?.sections[0];
if (!firstSection) throw new Error("missing section");
firstSection.layouts = [
{
layoutRef: "layout-1",
parentSectionRef: "section-1",
xOffset: 0,
yOffset: 0,
width: 10,
height: 5,
},
];
const json = bundleToJson(bundle);
const parsed = parseBundleJson(json);
expect(parsed.boards[0]?.sections[0]?.layouts).toHaveLength(1);
expect(parsed.boards[0]?.sections[0]?.layouts?.[0]?.width).toBe(10);
});
test("empty apps and integrations are valid", () => {
const bundle = createMinimalBundle({ apps: [], integrations: [] });
if (bundle.boards[0]) {
bundle.boards[0].items = [];
}
const json = bundleToJson(bundle);
const parsed = parseBundleJson(json);
expect(parsed.apps).toHaveLength(0);
expect(parsed.integrations).toHaveLength(0);
});
});
describe("bundleFilenameForBoard", () => {
test("sanitizes board name for filename", () => {
expect(bundleFilenameForBoard("My Board")).toBe("homarr-board-My-Board.json");
expect(bundleFilenameForBoard("test_board-1")).toBe("homarr-board-test_board-1.json");
expect(bundleFilenameForBoard("special!@#chars")).toBe("homarr-board-special---chars.json");
});
});
@@ -0,0 +1,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;
};
+199
View File
@@ -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>;
+48
View File
@@ -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[];
};
+74
View File
@@ -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,
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+1 -14
View File
@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
import { updateBoardName } from "./updater";
@@ -29,8 +28,6 @@ export const BoardProvider = ({
},
);
setActiveQueryCacheBoardId(data.id);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
@@ -45,16 +42,6 @@ export const BoardProvider = ({
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setActiveQueryCacheBoardId(data.id);
return () => {
if (getActiveQueryCacheBoardId() === data.id) {
setActiveQueryCacheBoardId(null);
}
};
}, [data.id]);
return (
<BoardContext.Provider
value={{
@@ -86,7 +73,7 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+1
View File
@@ -26,6 +26,7 @@
"dependencies": {
"@drizzle-team/brocli": "catalog:",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more