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
312 changed files with 28713 additions and 1312 deletions

No files matched your search

+8
View File
@@ -1,4 +1,5 @@
Dockerfile Dockerfile
Dockerfile.dev
.dockerignore .dockerignore
node_modules node_modules
**/node_modules **/node_modules
@@ -14,3 +15,10 @@ apps/nextjs/.next
.env .env
.env.* .env.*
!.env.example !.env.example
.docker-data
.docker-cache
coverage
.cursor
agent-transcripts
**/*.spec.ts
**/*.test.ts
+1
View File
@@ -33,6 +33,7 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.64.0
- 1.62.0 - 1.62.0
- 1.61.0 - 1.61.0
- 1.60.1-beta.1 - 1.60.1-beta.1
+4
View File
@@ -72,3 +72,7 @@ en.d.json.ts
# personal agent config # personal agent config
AGENTS.md AGENTS.md
.opencode .opencode
# local docker dev
.docker-data/
.docker-cache/
+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) ## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
### Bug Fixes ### Bug Fixes
+2 -1
View File
@@ -18,9 +18,11 @@
"@dnd-kit/modifiers": "catalog:", "@dnd-kit/modifiers": "catalog:",
"@dnd-kit/sortable": "catalog:", "@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:", "@dnd-kit/utilities": "catalog:",
"@gfazioli/mantine-onboarding-tour": "catalog:",
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0", "@homarr/boards": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
@@ -63,7 +65,6 @@
"@trpc/next": "catalog:", "@trpc/next": "catalog:",
"@trpc/react-query": "catalog:", "@trpc/react-query": "catalog:",
"@trpc/server": "catalog:", "@trpc/server": "catalog:",
"@xterm/addon-canvas": "catalog:",
"@xterm/addon-fit": "catalog:", "@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:", "@xterm/xterm": "catalog:",
"babel-plugin-react-compiler": "catalog:", "babel-plugin-react-compiler": "catalog:",
@@ -17,10 +17,13 @@ import {
import superjson from "superjson"; import superjson from "superjson";
import type { SuperJSONResult } from "superjson"; import type { SuperJSONResult } from "superjson";
import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
// window is not defined on server side // window is not defined on server side
@@ -56,6 +59,20 @@ export function TRPCReactProvider(props: PropsWithChildren) {
queries: { queries: {
staleTime: 5 * 1000, 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 { 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 { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url"; import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form"; import { LoginForm } from "./_login-form";
@@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </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"> <Card w={64 * 6} maw="90vw">
<LoginForm <LoginForm
providers={env.AUTH_PROVIDERS} providers={env.AUTH_PROVIDERS}
@@ -38,6 +38,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
layout: createBoardLayout({ layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />, headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
withTour: true,
}), }),
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => { page: async ({ params }: { params: Promise<TParams> }) => {
@@ -2,7 +2,8 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Group, Menu, ScrollArea } from "@mantine/core"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { import {
IconBox, IconBox,
@@ -11,6 +12,7 @@ import {
IconLayoutBoard, IconLayoutBoard,
IconPencil, IconPencil,
IconPencilOff, IconPencilOff,
IconPlug,
IconPlus, IconPlus,
IconReplace, IconReplace,
IconResize, IconResize,
@@ -36,14 +38,16 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
if (!hasChangeAccess) { if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />; return <SelectBoardsMenu />;
} }
@@ -51,11 +55,15 @@ export const BoardContentHeaderActions = () => {
<> <>
{isEditMode && <AddMenu />} {isEditMode && <AddMenu />}
<EditModeMenu /> <EditModeMenu demoMode={demoMode ?? false} />
<HeaderButton href={`/boards/${board.name}/settings`}> {!demoMode && (
<IconSettings stroke={1.5} /> <OnboardingTour.Target id="board-settings">
</HeaderButton> <HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
)}
<SelectBoardsMenu /> <SelectBoardsMenu />
</> </>
@@ -67,6 +75,7 @@ const AddMenu = () => {
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions(); const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions(); const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions(); const { createItem } = useItemActions();
@@ -108,6 +117,10 @@ const AddMenu = () => {
}); });
}, [openAppSelectModal, createItem]); }, [openAppSelectModal, createItem]);
const handleAddIntegration = useCallback(() => {
openIntegrationSelectModal({});
}, [openIntegrationSelectModal]);
return ( return (
<Menu position="bottom-end"> <Menu position="bottom-end">
<Menu.Target> <Menu.Target>
@@ -127,6 +140,10 @@ const AddMenu = () => {
{t("app.action.add")} {t("app.action.add")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}> <Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
@@ -141,7 +158,7 @@ const AddMenu = () => {
); );
}; };
const EditModeMenu = () => { const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -164,18 +181,28 @@ const EditModeMenu = () => {
}, },
}); });
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board); if (isEditMode) {
if (demoMode) return discardDemoChanges();
return saveBoard(board);
}
open(); open();
}, [board, isEditMode, saveBoard, open]); }, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
return ( return (
<HeaderButton onClick={toggle} loading={isPending}> <OnboardingTour.Target id="board-edit-mode">
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />} <HeaderButton onClick={toggle} loading={isPending}>
</HeaderButton> {isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
</OnboardingTour.Target>
); );
}; };
@@ -183,27 +210,31 @@ const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return ( return (
<Menu position="bottom-end"> <OnboardingTour.Target id="board-switcher">
<Menu.Target> <Box>
<HeaderButton w="auto" px={4}> <Menu position="bottom-end">
<IconReplace stroke={1.5} /> <Menu.Target>
</HeaderButton> <HeaderButton w="auto" px={4}>
</Menu.Target> <IconReplace stroke={1.5} />
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}> </HeaderButton>
<ScrollArea.Autosize mah={300}> </Menu.Target>
{boards.map((board) => ( <Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<Menu.Item <ScrollArea.Autosize mah={300}>
key={board.id} {boards.map((board) => (
component={Link} <Menu.Item
href={`/boards/${board.name}`} key={board.id}
leftSection={<IconLayoutBoard size={20} />} component={Link}
> href={`/boards/${board.name}`}
{board.name} leftSection={<IconLayoutBoard size={20} />}
</Menu.Item> >
))} {board.name}
</ScrollArea.Autosize> </Menu.Item>
</Menu.Dropdown> ))}
</Menu> </ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
</Box>
</OnboardingTour.Target>
); );
}; };
@@ -244,7 +275,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
}; };
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return; // Allow to reload in development if (env.NODE_ENV === "development") return;
event.preventDefault(); event.preventDefault();
event.returnValue = true; event.returnValue = true;
@@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { BoardTourProvider } from "~/components/onboarding/board-tour";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types"; import type { Board } from "./_types";
import type { Params } from "./(content)/_creator"; import type { Params } from "./(content)/_creator";
@@ -20,14 +21,21 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" }); const logger = createLogger({ module: "createBoardLayout" });
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
if (!hasSession) return <>{children}</>;
return <BoardTourProvider>{children}</BoardTourProvider>;
};
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
} }
export const createBoardLayout = <TParams extends Params>({ export const createBoardLayout = <TParams extends Params>({
headerActions, headerActions,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
withTour = false,
}: CreateBoardLayoutProps<TParams>) => { }: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({ const Layout = async ({
params, params,
@@ -61,14 +69,16 @@ export const createBoardLayout = <TParams extends Params>({
<EditModeProvider> <EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}> <BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss /> <CustomCss />
<ClientShell hasNavigation={false}> <BoardTourWrapper hasSession={withTour && !!session}>
<MainHeader <ClientShell hasNavigation={false}>
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} <MainHeader
actions={headerActions} logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
hasNavigation={false} actions={headerActions}
/> hasNavigation={false}
<AppShellMain>{children}</AppShellMain> />
</ClientShell> <AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourWrapper>
</BoardMantineProvider> </BoardMantineProvider>
</EditModeProvider> </EditModeProvider>
</BoardReadyProvider> </BoardReadyProvider>
@@ -0,0 +1,12 @@
import type { PropsWithChildren } from "react";
import { Card } from "@mantine/core";
interface InitStepCardProps {
withBorder?: boolean;
}
export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren<InitStepCardProps>) => (
<Card w="100%" withBorder={withBorder}>
{children}
</Card>
);
@@ -0,0 +1,2 @@
export const onboardingContentWidth = 64 * 16;
export const onboardingMaxWidth = "90vw";
@@ -6,11 +6,14 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { clearInitImportMode } from "./import/use-init-import-mode";
export const BackToStart = () => { export const BackToStart = () => {
const t = useI18n(); const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation(); const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => { const handleBackToStartAsync = async () => {
clearInitImportMode();
await mutateAsync(); await mutateAsync();
await revalidatePathActionAsync("/init"); await revalidatePathActionAsync("/init");
}; };
@@ -1,39 +1,27 @@
import type { MantineColor } from "@mantine/core"; import type { MantineColor } from "@mantine/core";
import { Button, Card, Stack, Text } from "@mantine/core"; import { Button, Stack, Text } from "@mantine/core";
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common"; import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { InitStepCard } from "../../_components/init-step-card";
export const InitFinish = async () => { export const InitFinish = async () => {
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
const tFinish = await getScopedI18n("init.step.finish"); const tFinish = await getScopedI18n("init.step.finish");
return ( return (
<Card w={64 * 6} maw="90vw"> <InitStepCard>
<Stack> <Stack>
<Text>{tFinish("description")}</Text> <Text>{tFinish("description")}</Text>
{firstBoard ? ( <InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
<InternalLinkButton {tFinish("action.goToBoard", { name: "dashboard" })}
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`} </InternalLinkButton>
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
>
{tFinish("action.goToBoard", { name: firstBoard.name })}
</InternalLinkButton>
) : (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/boards"
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
>
{tFinish("action.createBoard")}
</InternalLinkButton>
)}
{isProviderEnabled("credentials") && ( {isProviderEnabled("credentials") && (
<InternalLinkButton <InternalLinkButton
@@ -51,7 +39,7 @@ export const InitFinish = async () => {
{tFinish("action.docs")} {tFinish("action.docs")}
</ExternalLinkButton> </ExternalLinkButton>
</Stack> </Stack>
</Card> </InitStepCard>
); );
}; };
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Button, Card, Stack, TextInput } from "@mantine/core"; import { Button, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
@@ -10,6 +10,8 @@ import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group"; import { groupCreateSchema } from "@homarr/validation/group";
import { InitStepCard } from "../../_components/init-step-card";
export const InitGroup = () => { export const InitGroup = () => {
const t = useI18n(); const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({ const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
@@ -34,7 +36,7 @@ export const InitGroup = () => {
}; };
return ( return (
<Card w={64 * 6} maw="90vw"> <InitStepCard>
<form onSubmit={form.onSubmit(handleSubmitAsync)}> <form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack> <Stack>
<TextInput <TextInput
@@ -48,6 +50,6 @@ export const InitGroup = () => {
</Button> </Button>
</Stack> </Stack>
</form> </form>
</Card> </InitStepCard>
); );
}; };
@@ -1,10 +1,12 @@
import { ActionIcon, Button, Card, Group, Text } from "@mantine/core"; import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone"; import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react"; import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
interface FileInfoCardProps { interface FileInfoCardProps {
file: FileWithPath; file: FileWithPath;
onRemove: () => void; onRemove: () => void;
@@ -13,7 +15,7 @@ interface FileInfoCardProps {
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo"); const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return ( return (
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}> <InitStepCard withBorder={false}>
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Group> <Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}> <Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
@@ -36,6 +38,6 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Card> </InitStepCard>
); );
}; };
@@ -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>
);
};
@@ -1,17 +1,30 @@
"use client"; "use client";
import { startTransition, useState } from "react"; import { startTransition, useState } from "react";
import { Card, Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone"; import type { FileWithPath } from "@mantine/dropzone";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { InitialOldmarrImport } from "@homarr/old-import/components"; import { InitialOldmarrImport } from "@homarr/old-import/components";
import { InitStepCard } from "../../_components/init-step-card";
import { FileInfoCard } from "./file-info-card"; import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone"; import { ImportDropZone } from "./import-dropzone";
import { InitConfigImport } from "./init-config-import";
import { useInitImportMode } from "./use-init-import-mode";
export const InitImport = () => { export const InitImport = () => {
const importMode = useInitImportMode();
if (importMode === "config") {
return <InitConfigImport />;
}
return <LegacyInitImport />;
};
const LegacyInitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null); const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation(); const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>( const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
@@ -20,7 +33,7 @@ export const InitImport = () => {
if (!file) { if (!file) {
return ( return (
<Card w={64 * 12 + 8} maw="90vw"> <InitStepCard>
<ImportDropZone <ImportDropZone
loading={isPending} loading={isPending}
updateFile={(file) => { updateFile={(file) => {
@@ -40,12 +53,12 @@ export const InitImport = () => {
}); });
}} }}
/> />
</Card> </InitStepCard>
); );
} }
return ( return (
<Stack mb="sm"> <Stack w="100%" mb="sm">
<FileInfoCard file={file} onRemove={() => setFile(null)} /> <FileInfoCard file={file} onRemove={() => setFile(null)} />
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />} {analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
</Stack> </Stack>
@@ -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);
@@ -0,0 +1,209 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Avatar,
Badge,
Card,
Collapse,
Group,
Loader,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
export interface DiscoveredIntegration {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}
export interface DiscoveredApp {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}
interface DockerDiscoveryIndicatorProps {
integrations: DiscoveredIntegration[];
apps: DiscoveredApp[];
selectedAppIds: Set<string>;
onToggleApp: (containerId: string) => void;
isLoading: boolean;
}
const APP_CARD_HEIGHT = 80;
export const DockerDiscoveryIndicator = ({
integrations,
apps,
selectedAppIds,
onToggleApp,
isLoading,
}: DockerDiscoveryIndicatorProps) => {
const hasApps = apps.length > 0;
const [expanded, setExpanded] = useState(false);
const expandedOnce = useRef(false);
useEffect(() => {
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
expandedOnce.current = true;
setExpanded(true);
}
}, [hasApps, integrations.length]);
const t = useScopedI18n("init.step.integrations.docker");
const totalCount = integrations.length + apps.length;
if (!isLoading && totalCount === 0) return null;
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
if (isLoading) {
return (
<Paper
withBorder
p="sm"
radius="md"
style={{
borderColor: "var(--mantine-color-blue-4)",
background: "var(--mantine-color-blue-light)",
}}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{t("scanning")}
</Text>
<Text size="xs" c="dimmed">
{t("scanningDescription")}
</Text>
</Stack>
<Loader size="sm" color="blue" type="dots" />
</Group>
</Paper>
);
}
return (
<Paper
withBorder
p="sm"
radius="md"
style={{
borderColor: "var(--mantine-color-blue-4)",
background: "var(--mantine-color-blue-light)",
}}
>
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw={600}>
{t("detected", { count: String(totalCount) })}
</Text>
<Text size="xs" c="dimmed">
{t("detectedBreakdown", {
integrations: String(integrations.length),
apps: String(apps.length),
})}
</Text>
</Stack>
</Group>
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
style={{
transform: expanded ? "rotate(180deg)" : undefined,
transition: "transform 200ms ease",
}}
/>
</Group>
</UnstyledButton>
<Collapse expanded={expanded}>
<Stack gap="xs" mt="sm">
{integrations.length > 0 && (
<Stack gap={4}>
{integrations.map((integration) => (
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
<IntegrationAvatar kind={integration.kind} size="xs" />
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{integration.containerName}
</Text>
<Badge size="xs" variant="light" color="blue">
{getIntegrationName(integration.kind)}
</Badge>
</Group>
))}
</Stack>
)}
{hasApps && (
<Stack gap="xs">
<Text size="xs" fw={500}>
{t("appSectionLabel", { count: String(selectedAppCount) })}
</Text>
<ScrollArea.Autosize mah="30vh">
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
{apps.map((app) => {
const isSelected = selectedAppIds.has(app.containerId);
return (
<Card
key={app.containerId}
h={APP_CARD_HEIGHT}
p="xs"
withBorder
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
opacity: isSelected ? 1 : 0.6,
}}
onClick={() => onToggleApp(app.containerId)}
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
</Stack>
</Card>
);
})}
</SimpleGrid>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
</Collapse>
</Paper>
);
};
@@ -0,0 +1,483 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
ActionIcon,
Alert,
Avatar,
Button,
Card,
Center,
Collapse,
Group,
Loader,
Paper,
Progress,
SegmentedControl,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconArrowRight,
IconBrandDocker,
IconCheck,
IconChevronDown,
IconSettings,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
import { InitStepCard } from "../../_components/init-step-card";
type Phase = "select" | "configure" | "done";
interface IntegrationDraft {
kind: IntegrationKind;
initialUrl: string;
initialName?: string;
dockerPort?: number | null;
source: "manual" | "docker";
}
export const InitIntegrations = () => {
const [phase, setPhase] = useState<Phase>("select");
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [baseHost, setBaseHost] = useState("");
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("subdomain");
const t = useScopedI18n("init.step.integrations");
const router = useRouter();
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
undefined,
{
retry: false,
refetchOnWindowFocus: false,
},
);
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
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 [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
const [appsExpanded, setAppsExpanded] = useState(false);
const dockerApplied = useRef(false);
useEffect(() => {
if (dockerData?.status !== "success" || dockerApplied.current) return;
dockerApplied.current = true;
const newDrafts = new Map(drafts);
const newKinds = new Set(selectedKinds);
for (const item of dockerData.integrations) {
if (!newDrafts.has(item.kind)) {
newDrafts.set(item.kind, {
kind: item.kind,
initialUrl: item.suggestedUrl,
initialName: item.containerName,
dockerPort: item.publishedPort,
source: "docker",
});
}
newKinds.add(item.kind);
}
setDrafts(newDrafts);
setSelectedKinds(Array.from(newKinds));
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
if (dockerData.apps.length > 0) {
setAppsExpanded(true);
}
}, [dockerData]);
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
setSelectedKinds(kinds);
}, []);
const handleToggleApp = useCallback((containerId: string) => {
setSelectedAppIds((prev) => {
const next = new Set(prev);
if (next.has(containerId)) {
next.delete(containerId);
} else {
next.add(containerId);
}
return next;
});
}, []);
const handleContinueToConfig = () => {
const newDrafts = new Map(drafts);
for (const kind of selectedKinds) {
const existing = newDrafts.get(kind);
if (baseHost) {
newDrafts.set(kind, {
kind,
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
initialName: existing?.initialName,
dockerPort: existing?.dockerPort,
source: existing?.source ?? "manual",
});
} else if (!existing) {
newDrafts.set(kind, { kind, initialUrl: "", source: "manual" });
}
}
setDrafts(newDrafts);
setCurrentIndex(0);
setPhase("configure");
};
const finishSetupAsync = async () => {
setPhase("done");
try {
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
if (appsToCreate.length > 0) {
await createApps(
appsToCreate.map((app) => {
const href = baseHost
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
: app.suggestedUrl;
return {
name: app.containerName,
href: href || null,
iconUrl: app.iconUrl,
};
}),
);
}
await setupIntegrations();
router.refresh();
} catch {
showErrorNotification({
title: t("configure.error.title"),
message: t("configure.error.message"),
});
setPhase("select");
}
};
const advanceOrFinish = () => {
if (currentIndex + 1 >= selectedKinds.length) {
void finishSetupAsync();
} else {
setCurrentIndex((i) => i + 1);
}
};
const backToSelect = () => {
setPhase("select");
setCurrentIndex(0);
};
if (phase === "done") {
return (
<InitStepCard>
<Center py="xl">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text>{t("configure.finishing")}</Text>
</Stack>
</Center>
</InitStepCard>
);
}
if (phase === "configure") {
return (
<ConfigurePhase
selectedKinds={selectedKinds}
currentIndex={currentIndex}
drafts={drafts}
baseHost={baseHost}
setBaseHost={setBaseHost}
urlMode={urlMode}
setUrlMode={setUrlMode}
onSuccess={advanceOrFinish}
onCancel={backToSelect}
onSkip={advanceOrFinish}
/>
);
}
const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length;
return (
<InitStepCard>
<Stack>
<Text>{t("description")}</Text>
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("urlTemplate.warning")}
</Text>
<Group gap="sm" grow>
<TextInput
placeholder={t("urlTemplate.placeholder")}
value={baseHost}
onChange={(e) => setBaseHost(e.currentTarget.value)}
data-autofocus
/>
<SegmentedControl
value={urlMode}
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
data={[
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
]}
style={{ flex: "0 0 auto" }}
/>
</Group>
{baseHost && (
<Text size="xs" c="dimmed">
{t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })}
</Text>
)}
</Stack>
</Alert>
{isDockerLoading && (
<Paper withBorder p="sm" radius="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Text size="sm" fw={500}>
{t("docker.scanning")}
</Text>
<Loader size="sm" color="blue" type="dots" />
</Group>
</Paper>
)}
{!isDockerLoading && totalDetected > 0 && (
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Text size="sm">
{t("docker.summary", {
apps: String(totalDetected),
integrations: String(dockerIntegrations.length),
})}
</Text>
</Group>
</Paper>
)}
<IntegrationMultiSelectGrid
selectedKinds={selectedKinds}
onSelectionChange={handleSelectionChange}
detectedKinds={detectedKinds}
onboarding
/>
{dockerApps.length > 0 && (
<Paper withBorder p="sm" radius="md">
<Stack gap="xs">
<Group
justify="space-between"
wrap="nowrap"
style={{ cursor: "pointer" }}
onClick={() => setAppsExpanded((prev) => !prev)}
>
<Text size="sm" fw={500}>
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
</Text>
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
style={{
transform: appsExpanded ? "rotate(180deg)" : undefined,
transition: "transform 200ms ease",
}}
/>
</Group>
<Collapse expanded={appsExpanded}>
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
{dockerApps.map((app) => {
const isSelected = selectedAppIds.has(app.containerId);
return (
<Card
key={app.containerId}
h={80}
p="xs"
withBorder
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
opacity: isSelected ? 1 : 0.6,
}}
onClick={() => handleToggleApp(app.containerId)}
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Collapse>
</Stack>
</Paper>
)}
<Group justify="space-between">
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
{t("action.skip")}
</Button>
<Button
onClick={handleContinueToConfig}
disabled={selectedKinds.length === 0 || undefined}
rightSection={<IconArrowRight size={16} stroke={1.5} />}
suppressHydrationWarning
>
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
</Button>
</Group>
</Stack>
</InitStepCard>
);
};
interface ConfigurePhaseProps {
selectedKinds: IntegrationKind[];
currentIndex: number;
drafts: Map<IntegrationKind, IntegrationDraft>;
baseHost: string;
setBaseHost: (value: string) => void;
urlMode: UrlTemplateMode;
setUrlMode: (value: UrlTemplateMode) => void;
onSuccess: () => void;
onCancel: () => void;
onSkip: () => void;
}
const ConfigurePhase = ({
selectedKinds,
currentIndex,
drafts,
baseHost,
setBaseHost,
urlMode,
setUrlMode,
onSuccess,
onCancel,
onSkip,
}: ConfigurePhaseProps) => {
const t = useScopedI18n("init.step.integrations");
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
const currentKind = selectedKinds[currentIndex]!;
const draft = drafts.get(currentKind);
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
const computedUrl = baseHost
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
: draft?.initialUrl;
return (
<InitStepCard>
<Stack>
<Group justify="space-between" align="center">
<Group gap="xs">
<IntegrationAvatar kind={currentKind} size="sm" />
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
</Group>
<Tooltip label={t("configure.helpers.tooltip")}>
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
<IconSettings size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
<Stack gap={4}>
<Progress value={progress} size="sm" />
<Text size="xs" c="dimmed" ta="center">
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
</Text>
</Stack>
<Collapse expanded={helpersOpen}>
<Stack
gap="xs"
p="xs"
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
>
<Text size="xs" c="dimmed">
{t("configure.helpers.description")}
</Text>
<Group gap="sm" grow>
<TextInput
size="xs"
placeholder={t("urlTemplate.placeholder")}
value={baseHost}
onChange={(e) => setBaseHost(e.currentTarget.value)}
/>
<SegmentedControl
size="xs"
value={urlMode}
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
data={[
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
]}
style={{ flex: "0 0 auto" }}
/>
</Group>
{baseHost && (
<Text size="xs" c="dimmed">
{t("urlTemplate.preview", {
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
})}
</Text>
)}
</Stack>
</Collapse>
<NewIntegrationForm
key={currentKind}
kind={currentKind}
initialUrl={computedUrl}
initialName={draft?.initialName}
onSuccess={onSuccess}
onCancel={onCancel}
onSkip={onSkip}
isOnboarding
/>
</Stack>
</InitStepCard>
);
};
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core"; import { Button, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
@@ -13,6 +13,8 @@ import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { settingsInitSchema } from "@homarr/validation/settings"; import { settingsInitSchema } from "@homarr/validation/settings";
import { InitStepCard } from "../../_components/init-step-card";
export const InitSettings = () => { export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section"); const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n(); const t = useI18n();
@@ -29,8 +31,8 @@ export const InitSettings = () => {
return ( return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}> <form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack> <Stack w="100%">
<Card w={64 * 12 + 8} maw="90vw"> <InitStepCard>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text> <Text fw={500}>{tSection("analytics.title")}</Text>
@@ -38,8 +40,8 @@ export const InitSettings = () => {
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} /> <AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
</Stack> </Stack>
</Stack> </Stack>
</Card> </InitStepCard>
<Card w={64 * 12 + 8} maw="90vw"> <InitStepCard>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text> <Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
@@ -62,7 +64,7 @@ export const InitSettings = () => {
/> />
</Stack> </Stack>
</Stack> </Stack>
</Card> </InitStepCard>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}> <Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")} {t("common.action.continue")}
@@ -1,16 +1,17 @@
import { Card, Stack, Text } from "@mantine/core"; 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 { getMantineColor } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitStartButton } from "./next-button"; import { InitStartButton } from "./next-button";
export const InitStart = async () => { export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start"); const tStart = await getScopedI18n("init.step.start");
return ( return (
<Card w={64 * 6} maw="90vw"> <InitStepCard>
<Stack> <Stack>
<Text>{tStart("description")}</Text> <Text>{tStart("description")}</Text>
@@ -22,11 +23,19 @@ export const InitStart = async () => {
</InitStartButton> </InitStartButton>
<InitStartButton <InitStartButton
preferredStep="import" preferredStep="import"
importMode="legacy"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />} icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
> >
{tStart("action.importOldmarr")} {tStart("action.importOldmarr")}
</InitStartButton> </InitStartButton>
<InitStartButton
preferredStep="import"
importMode="config"
icon={<IconJson color={getMantineColor("blue", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importConfig")}
</InitStartButton>
</Stack> </Stack>
</Card> </InitStepCard>
); );
}; };
@@ -7,15 +7,28 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions"; import type { OnboardingStep } from "@homarr/definitions";
import { clearInitImportMode, setInitImportMode, type InitImportMode } from "../import/use-init-import-mode";
interface InitStartButtonProps { interface InitStartButtonProps {
icon: ReactNode; icon: ReactNode;
preferredStep: OnboardingStep | undefined; 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 { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => { const handleClickAsync = async () => {
if (importMode) {
setInitImportMode(importMode);
} else {
clearInitImportMode();
}
await mutateAsync({ preferredStep }); await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init"); await revalidatePathActionAsync("/init");
}; };
@@ -1,11 +1,10 @@
import { Card } from "@mantine/core"; import { InitStepCard } from "../../_components/init-step-card";
import { InitUserForm } from "./init-user-form"; import { InitUserForm } from "./init-user-form";
export const InitUser = () => { export const InitUser = () => {
return ( return (
<Card w={64 * 6} maw="90vw"> <InitStepCard>
<InitUserForm /> <InitUserForm />
</Card> </InitStepCard>
); );
}; };
+4 -1
View File
@@ -9,10 +9,12 @@ import { getScopedI18n } from "@homarr/translation/server";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox"; import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
import { BackToStart } from "./_steps/back"; import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish"; import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group"; import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import"; import { InitImport } from "./_steps/import/init-import";
import { InitIntegrations } from "./_steps/integrations/init-integrations";
import { InitSettings } from "./_steps/settings/init-settings"; import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start"; import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user"; import { InitUser } from "./_steps/user/init-user";
@@ -23,6 +25,7 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
user: InitUser, user: InitUser,
group: InitGroup, group: InitGroup,
settings: InitSettings, settings: InitSettings,
integrations: InitIntegrations,
finish: InitFinish, finish: InitFinish,
}; };
@@ -35,7 +38,7 @@ export default async function InitPage() {
return ( return (
<Box mih="100dvh"> <Box mih="100dvh">
<Center> <Center>
<Stack align="center" mt="xl"> <Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
<HomarrLogoWithTitle size="lg" /> <HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center"> <Stack gap={6} align="center">
<Title order={3} fw={400} ta="center"> <Title order={3} fw={400} ta="center">
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css"; import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css"; import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css"; import "@homarr/ui/styles.css";
@@ -25,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics"; import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
@@ -45,9 +46,11 @@ export default async function AppsPage(props: AppsPageProps) {
title={t("page.list.title")} title={t("page.list.title")}
primaryAction={ primaryAction={
canCreate ? ( canCreate ? (
<MobileAffixButton component={Link} href="/manage/apps/new"> <TourTarget id="manage-apps-create">
{t("page.create.title")} <MobileAffixButton component={Link} href="/manage/apps/new">
</MobileAffixButton> {t("page.create.title")}
</MobileAffixButton>
</TourTarget>
) : undefined ) : undefined
} }
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />} toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
@@ -56,11 +59,13 @@ export default async function AppsPage(props: AppsPageProps) {
> >
{apps.length === 0 && <AppNoResults />} {apps.length === 0 && <AppNoResults />}
{apps.length > 0 && ( {apps.length > 0 && (
<Stack gap="sm"> <TourTarget id="manage-apps-list">
{apps.map((app) => ( <Stack gap="sm">
<AppCard key={app.id} app={app} /> {apps.map((app) => (
))} <AppCard key={app.id} app={app} />
</Stack> ))}
</Stack>
</TourTarget>
)} )}
</ManagePageLayout> </ManagePageLayout>
); );
@@ -2,14 +2,14 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Menu } from "@mantine/core"; 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 type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; 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 { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
@@ -28,7 +28,10 @@ interface BoardCardMenuDropdownProps {
} }
export const BoardCardMenuDropdown = ({ board }: 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 tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board); const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
@@ -36,6 +39,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal); const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const { openModal: openExportModal } = useModalAction(ExportBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({ const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => { onSettled: async () => {
@@ -87,6 +91,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
}); });
}, [board.id, board.name, openDuplicateModal]); }, [board.id, board.name, openDuplicateModal]);
const handleExportBoard = useCallback(() => {
openExportModal({
board: {
id: board.id,
name: board.name,
},
});
}, [board.id, board.name, openExportModal]);
return ( return (
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}> <Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
@@ -95,6 +108,9 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}> <Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
{t("setMobileHomeBoard.label")} {t("setMobileHomeBoard.label")}
</Menu.Item> </Menu.Item>
<Menu.Item onClick={handleExportBoard} leftSection={<IconDownload {...iconProps} />}>
{t("export.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && ( {session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}> <Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")} {t("duplicate.label")}
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => { export const CreateBoardButton = () => {
const t = useI18n(); const t = useI18n() as unknown as (key: string) => string;
const { openModal: openAddModal } = useModalAction(AddBoardModal); const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal); const { openModal: openImportModal } = useModalAction(ImportBoardModal);
@@ -28,6 +28,7 @@ import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
@@ -42,18 +43,26 @@ export default async function ManageBoardsPage() {
return ( return (
<ManagePageLayout <ManagePageLayout
title={t("title")} title={t("title")}
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined} primaryAction={
canCreateBoards ? (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateBoards} floatingPrimaryAction={canCreateBoards}
> >
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />} {boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
{boards.length > 0 && ( {boards.length > 0 && (
<Grid> <TourTarget id="manage-boards-list">
{boards.map((board) => ( <Grid>
<GridCol span={{ base: 12, md: 6 }} key={board.id}> {boards.map((board) => (
<BoardCard board={board} /> <GridCol span={{ base: 12, md: 6 }} key={board.id}>
</GridCol> <BoardCard board={board} />
))} </GridCol>
</Grid> ))}
</Grid>
</TourTarget>
)} )}
</ManagePageLayout> </ManagePageLayout>
); );
@@ -1,59 +0,0 @@
"use client";
import type { ChangeEvent } from "react";
import React, { useMemo, useState } from "react";
import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar, Link } from "@homarr/ui";
interface IntegrationCreateDropdownContentProps {
enableMockIntegration: boolean;
}
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
const t = useI18n();
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds
.filter((kind) => enableMockIntegration || kind !== "mock")
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
}, [search, enableMockIntegration]);
const handleSearch = React.useCallback(
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
[setSearch],
);
return (
<Flex direction={{ base: "column-reverse", md: "column" }} gap="sm">
<TextInput
leftSection={<IconSearch stroke={1.5} size={20} />}
placeholder={t("integration.page.list.search")}
value={search}
data-autofocus
onChange={handleSearch}
variant="filled"
/>
{filteredKinds.length > 0 ? (
<ScrollArea.Autosize mah={384}>
{filteredKinds.map((kind) => (
<Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
<Group>
<IntegrationAvatar kind={kind} size="sm" />
<Text size="sm">{getIntegrationName(kind)}</Text>
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
) : (
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
)}
</Flex>
);
};
@@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import type { IntegrationKind } from "@homarr/definitions";
import { NewIntegrationForm } from "./_integration-new-form";
interface IntegrationNewFormWrapperProps {
kind: IntegrationKind;
initialUrl?: string;
initialName?: string;
}
export const IntegrationNewFormWrapper = ({ kind, initialUrl, initialName }: IntegrationNewFormWrapperProps) => {
const router = useRouter();
return (
<NewIntegrationForm
kind={kind}
initialUrl={initialUrl}
initialName={initialName}
onSuccess={() => router.push("/manage/integrations")}
/>
);
};
@@ -1,9 +1,9 @@
"use client"; "use client";
import { startTransition, useState } from "react"; import { startTransition, useState } from "react";
import { useRouter } from "next/navigation";
import { import {
Alert, Alert,
Anchor,
Button, Button,
Checkbox, Checkbox,
Collapse, Collapse,
@@ -16,7 +16,7 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { IconCheck, IconInfoCircle } from "@tabler/icons-react"; import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -27,6 +27,7 @@ import type { IntegrationKind } from "@homarr/definitions";
import { import {
getAllSecretKindOptions, getAllSecretKindOptions,
getIconUrl, getIconUrl,
getIntegrationApiKeyUrl,
getIntegrationDefaultUrl, getIntegrationDefaultUrl,
getIntegrationName, getIntegrationName,
integrationDefs, integrationDefs,
@@ -45,9 +46,13 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types"; import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
interface NewIntegrationFormProps { interface NewIntegrationFormProps {
searchParams: Partial<z.infer<typeof integrationCreateSchema>> & { kind: IntegrationKind;
kind: IntegrationKind; initialUrl?: string;
}; initialName?: string;
onSuccess: () => void;
onCancel?: () => void;
onSkip?: () => void;
isOnboarding?: boolean;
} }
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
@@ -58,39 +63,48 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
}), }),
); );
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { export const NewIntegrationForm = ({
kind,
initialUrl,
initialName,
onSuccess,
onCancel,
onSkip,
isOnboarding = false,
}: NewIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind); const secretKinds = getAllSecretKindOptions(kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const router = useRouter();
const { data: session } = useSession(); const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false; const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false);
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? ""; let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
if (hasUrlSecret) { if (hasUrlSecret) {
// Placeholder Url, replaced with origin of the secret Url on submit
url = "http://localhost"; url = "http://localhost";
} }
const form = useZodForm(formSchema, { const form = useZodForm(formSchema, {
initialValues: { initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind), name: initialName ?? getIntegrationName(kind),
url, url,
secrets: secretKinds[0].map((kind) => ({ secrets: secretKinds[0].map((kind) => ({
kind, kind,
value: "", value: "",
})), })),
attemptSearchEngineCreation: true, attemptSearchEngineCreation: !isOnboarding,
hasApp: false, hasApp: !isOnboarding,
appHref: url, appHref: isOnboarding ? null : url,
appId: null, appId: null,
}, },
}); });
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({ const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({
async onSuccess() { async onSuccess() {
await revalidatePathActionAsync("/manage/integrations"); await revalidatePathActionAsync("/manage/integrations");
}, },
}); });
const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } =
clientApi.onboard.createIntegration.useMutation();
const isPending = isCreatePending || isOnboardingCreatePending;
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null); const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => { const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
@@ -98,6 +112,39 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url; : values.url;
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
if (data && "error" in data && data.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
return;
}
showSuccessNotification({
title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"),
});
onSuccess();
};
const onMutationError = () => {
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
};
if (isOnboarding) {
await createOnboardingIntegrationAsync(
{ kind, name: values.name, url, secrets: values.secrets },
{ onSuccess: onMutationSuccess, onError: onMutationError },
);
return;
}
const hasCustomHref = appHref !== null && appHref.trim().length >= 1; const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
const app = hasApp const app = hasApp
@@ -106,49 +153,21 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
: { : {
name: values.name, name: values.name,
href: hasCustomHref ? appHref : url, href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(searchParams.kind), iconUrl: getIconUrl(kind),
description: null, description: null,
pingUrl: url, pingUrl: url,
} }
: undefined; : undefined;
await createIntegrationAsync( await createIntegrationAsync(
{ { kind, ...values, url, app },
kind: searchParams.kind, { onSuccess: onMutationSuccess, onError: onMutationError },
...values,
url,
app,
},
{
onSuccess(data) {
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
return;
}
showSuccessNotification({
title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"),
});
router.push("/manage/integrations");
},
onError: () => {
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
},
},
); );
}; };
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search"); const integrationCategories = integrationDefs[kind].category.flat();
const supportsSearchEngine =
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
@@ -175,27 +194,39 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text> <Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert> </Alert>
)} )}
<ApiKeySettingsLink kind={kind} url={form.values.url} />
</Stack> </Stack>
</Fieldset> </Fieldset>
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />} {error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
{supportsSearchEngine && ( {!isOnboarding && supportsSearchEngine && (
<Checkbox <Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")} label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", { description={t("integration.field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(searchParams.kind), kind: getIntegrationName(kind),
})} })}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })} {...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
/> />
)} )}
<AppForm form={form} canCreateApps={canCreateApps} /> {!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
<Group justify="end" align="center"> <Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations"> {onCancel ? (
{t("common.action.backToOverview")} <Button variant="default" onClick={onCancel}>
</Button> {t("common.action.backToOverview")}
</Button>
) : (
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
)}
{onSkip && (
<Button variant="default" onClick={onSkip}>
{t("common.action.skip")}
</Button>
)}
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending}>
{t("integration.testConnection.action.create")} {t("integration.testConnection.action.create")}
</Button> </Button>
@@ -266,6 +297,40 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
); );
}; };
const normalizeUrl = (raw: string): string | null => {
try {
return new URL(raw).href;
} catch {
// Bare IP / hostname — prepend http:// and retry
}
try {
return new URL(`http://${raw}`).href;
} catch {
return null;
}
};
const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => {
const t = useI18n();
const apiKeyUrl = getIntegrationApiKeyUrl(url, kind);
if (!apiKeyUrl) return null;
const resolved = normalizeUrl(url);
if (!resolved) return null;
const fullApiKeyUrl = getIntegrationApiKeyUrl(resolved, kind);
return (
<Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm">
<Group gap={4}>
<IconKey size={14} stroke={1.5} />
<Text size="sm">{t("integration.field.apiKeySettings.label")}</Text>
<IconExternalLink size={14} stroke={1.5} />
</Group>
</Anchor>
);
};
type IntegrationAppSelectProps = Modify< type IntegrationAppSelectProps = Modify<
GetInputPropsReturnType, GetInputPropsReturnType,
{ {
@@ -0,0 +1,21 @@
"use client";
import { useRouter } from "next/navigation";
import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationSelectGrid } from "~/components/integration/integration-select-grid";
interface IntegrationNewSelectGridProps {
enableMockIntegration: boolean;
}
export const IntegrationNewSelectGrid = ({ enableMockIntegration }: IntegrationNewSelectGridProps) => {
const router = useRouter();
const handleSelect = (kind: IntegrationKind) => {
router.push(`/manage/integrations/new?kind=${kind}`);
};
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={enableMockIntegration} />;
};
@@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import type { integrationCreateSchema } from "@homarr/validation/integration";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NewIntegrationForm } from "./_integration-new-form"; import { env } from "~/env";
import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper";
import { IntegrationNewSelectGrid } from "./_integration-new-select-grid";
interface NewIntegrationPageProps { interface NewIntegrationPageProps {
searchParams: Promise< searchParams: Promise<{
Partial<z.infer<typeof integrationCreateSchema>> & { kind?: string;
kind: IntegrationKind; url?: string;
} name?: string;
>; }>;
} }
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) { export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
@@ -28,8 +28,20 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
} }
const result = z.enum(integrationKinds).safeParse(searchParams.kind); const result = z.enum(integrationKinds).safeParse(searchParams.kind);
if (!result.success) { if (!result.success) {
notFound(); const t = await getScopedI18n("integration");
return (
<>
<DynamicBreadcrumb />
<Container>
<Stack>
<Title>{t("action.create")}</Title>
<IntegrationNewSelectGrid enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</Stack>
</Container>
</>
);
} }
const tCreate = await getScopedI18n("integration.page.create"); const tCreate = await getScopedI18n("integration.page.create");
@@ -45,7 +57,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
<IntegrationAvatar kind={currentKind} size="md" /> <IntegrationAvatar kind={currentKind} size="md" />
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title> <Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
</Group> </Group>
<NewIntegrationForm searchParams={searchParams} /> <IntegrationNewFormWrapper kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
</Stack> </Stack>
</Container> </Container>
</> </>
@@ -1,5 +1,4 @@
import { Fragment } from "react"; import { Fragment } from "react";
import type { PropsWithChildren } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
AccordionControl, AccordionControl,
@@ -8,12 +7,8 @@ import {
ActionIcon, ActionIcon,
ActionIconGroup, ActionIconGroup,
Anchor, Anchor,
Button,
Divider, Divider,
Group, Group,
Menu,
MenuDropdown,
MenuTarget,
Stack, Stack,
Table, Table,
TableTbody, TableTbody,
@@ -23,7 +18,7 @@ import {
TableTr, TableTr,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react"; import { IconPencil, IconPlugX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -34,13 +29,12 @@ import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui"; import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { env } from "~/env";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
import classes from "./page.module.css"; import classes from "./page.module.css";
interface IntegrationsPageProps { interface IntegrationsPageProps {
@@ -67,41 +61,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
title={t("page.list.title")} title={t("page.list.title")}
primaryAction={ primaryAction={
canCreateIntegrations ? ( canCreateIntegrations ? (
<ManageMobilePrimaryAction> <TourTarget id="manage-integrations-create">
<IntegrationSelectMenu> <MobileAffixButton component={Link} href="/manage/integrations/new">
<MenuTarget> {t("action.create")}
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button> </MobileAffixButton>
</MenuTarget> </TourTarget>
</IntegrationSelectMenu>
</ManageMobilePrimaryAction>
) : undefined ) : undefined
} }
floatingPrimaryAction={canCreateIntegrations} floatingPrimaryAction={canCreateIntegrations}
> >
<IntegrationList integrations={integrations} activeTab={searchParams.tab} /> <TourTarget id="manage-integrations-list">
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</TourTarget>
</ManagePageLayout> </ManagePageLayout>
); );
} }
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
return (
<Menu
width={256}
trapFocus
position="bottom-end"
withinPortal
shadow="md"
keepMounted={false}
withInitialFocusPlaceholder={false}
>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</MenuDropdown>
</Menu>
);
};
interface IntegrationListProps { interface IntegrationListProps {
integrations: RouterOutputs["integration"]["all"]; integrations: RouterOutputs["integration"]["all"];
activeTab?: IntegrationKind; activeTab?: IntegrationKind;
@@ -134,12 +109,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
return ( return (
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}> <ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{entries.map(([kind, integrations], index) => ( {entries.map(([kind, kindIntegrations], index) => (
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}> <AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}> <AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group> <Group>
<Text>{getIntegrationName(kind)}</Text> <Text>{getIntegrationName(kind)}</Text>
<CountBadge count={integrations.length} /> <CountBadge count={kindIntegrations.length} />
</Group> </Group>
</AccordionControl> </AccordionControl>
<AccordionPanel> <AccordionPanel>
@@ -152,7 +127,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{integrations.map((integration) => ( {kindIntegrations.map((integration) => (
<TableTr key={integration.id}> <TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd> <TableTd>{integration.name}</TableTd>
<TableTd> <TableTd>
@@ -173,7 +148,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
> >
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} /> <DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup> </ActionIconGroup>
)} )}
</Group> </Group>
@@ -184,9 +159,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</Table> </Table>
<Stack gap="xs" hiddenFrom="md"> <Stack gap="xs" hiddenFrom="md">
{integrations.map((integration, index) => ( {kindIntegrations.map((integration, integrationIndex) => (
<Fragment key={integration.id}> <Fragment key={integration.id}>
{index !== 0 && <Divider />} {integrationIndex !== 0 && <Divider />}
<Stack gap={0}> <Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text> <Text>{integration.name}</Text>
@@ -201,7 +176,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
> >
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} /> <DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup> </ActionIconGroup>
)} )}
</Group> </Group>
+23 -1
View File
@@ -10,6 +10,7 @@ import {
IconBrandTablerFilled, IconBrandTablerFilled,
IconCertificate, IconCertificate,
IconClipboardListFilled, IconClipboardListFilled,
IconDatabaseExport,
IconDirectionsFilled, IconDirectionsFilled,
IconGitFork, IconGitFork,
IconHelpSquareRoundedFilled, IconHelpSquareRoundedFilled,
@@ -36,6 +37,7 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation"; import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
export default async function ManageLayout({ children }: PropsWithChildren) { export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar"); const t = await getScopedI18n("management.navbar");
@@ -45,11 +47,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"), label: t("items.home"),
icon: IconHomeFilled, icon: IconHomeFilled,
href: "/manage", href: "/manage",
"data-onboarding-tour-id": "manage-welcome",
}, },
{ {
icon: IconLayoutDashboardFilled, icon: IconLayoutDashboardFilled,
href: "/manage/boards", href: "/manage/boards",
label: t("items.boards"), label: t("items.boards"),
"data-onboarding-tour-id": "manage-boards",
}, },
{ {
icon: IconBox, icon: IconBox,
@@ -59,12 +63,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
"data-onboarding-tour-id": "manage-apps",
}, },
{ {
icon: IconAffiliateFilled, icon: IconAffiliateFilled,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session, hidden: !session,
"data-onboarding-tour-id": "manage-integrations",
}, },
{ {
icon: IconSearch, icon: IconSearch,
@@ -74,17 +80,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
"data-onboarding-tour-id": "manage-search-engines",
}, },
{ {
icon: IconPhotoFilled, icon: IconPhotoFilled,
href: "/manage/medias", href: "/manage/medias",
label: t("items.medias"), label: t("items.medias"),
hidden: !session, hidden: !session,
"data-onboarding-tour-id": "manage-medias",
}, },
{ {
icon: IconUserFilled, icon: IconUserFilled,
label: t("items.users.label"), label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-users",
items: [ items: [
{ {
label: t("items.users.items.manage"), label: t("items.users.items.manage"),
@@ -146,6 +155,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/tasks", href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
{
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
icon: IconDatabaseExport,
href: "/manage/tools/import-export",
hidden: !session?.user.permissions.includes("admin"),
},
], ],
}, },
{ {
@@ -153,6 +168,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/settings", href: "/manage/settings",
icon: IconSettingsFilled, icon: IconSettingsFilled,
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-settings",
}, },
{ {
label: t("items.help.label"), label: t("items.help.label"),
@@ -191,11 +207,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
]; ];
return ( const isAdmin = session?.user.permissions.includes("admin") ?? false;
const shell = (
<ClientShell hasNavigation> <ClientShell hasNavigation>
<MainHeader></MainHeader> <MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation> <MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain> <AppShellMain>{children}</AppShellMain>
</ClientShell> </ClientShell>
); );
if (!session) return shell;
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
} }
@@ -0,0 +1,58 @@
"use client";
import { NumberInput, Switch, TextInput } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["docker"] }) => {
const tDocker = useScopedI18n("management.page.settings.section.docker" as never) as unknown as (
key: string,
) => string;
return (
<CommonSettingsForm settingKey="docker" defaultValues={defaultValues}>
{(form) => (
<>
<TextInput
{...form.getInputProps("targetBoardName")}
value={form.getInputProps("targetBoardName").value ?? ""}
label={tDocker("targetBoardName.label")}
description={tDocker("targetBoardName.description")}
/>
<Switch
{...form.getInputProps("readHomepageLabels", { type: "checkbox" })}
label={tDocker("readHomepageLabels.label")}
description={tDocker("readHomepageLabels.description")}
/>
<Switch
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
label={tDocker("createIntegrations.label")}
description={tDocker("createIntegrations.description")}
/>
<Switch
{...form.getInputProps("pruneRemoved", { type: "checkbox" })}
label={tDocker("pruneRemoved.label")}
description={tDocker("pruneRemoved.description")}
/>
<NumberInput
{...form.getInputProps("defaultItemWidth")}
label={tDocker("defaultItemWidth.label")}
description={tDocker("defaultItemWidth.description")}
min={1}
max={10}
/>
<NumberInput
{...form.getInputProps("defaultItemHeight")}
label={tDocker("defaultItemHeight.label")}
description={tDocker("defaultItemHeight.description")}
min={1}
max={10}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -5,12 +5,15 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; 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 { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AnalyticsSettings } from "./_components/analytics.settings"; import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form"; import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form"; import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form"; import { CultureSettingsForm } from "./_components/culture-settings-form";
import { DockerSettingsForm } from "./_components/docker-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form"; import { SearchSettingsForm } from "./_components/search-settings-form";
import { UserSettingsForm } from "./_components/user-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> <Title order={2}>{tSettings("section.culture.title")}</Title>
<CultureSettingsForm defaultValues={serverSettings.culture} /> <CultureSettingsForm defaultValues={serverSettings.culture} />
</Stack> </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> </Stack>
</> </>
); );
@@ -1,23 +1,26 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core"; import type { MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core"; import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
import { import {
IconCategoryPlus, IconCategoryPlus,
IconDots,
IconFileText,
IconPlayerPlay, IconPlayerPlay,
IconPlayerStop, IconPlayerStop,
IconRefresh, IconRefresh,
IconRotateClockwise, IconRotateClockwise,
IconTrash, IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table"; import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common"; import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker"; import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap } from "@homarr/docker/shared"; import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection"; import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -27,25 +30,30 @@ import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
const createColumns = ( type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
t: TranslationFunction,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [ const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
{ {
accessorKey: "name", accessorKey: "name",
header: t("docker.field.name.label"), header: t("docker.field.name.label"),
Cell({ renderedCellValue, row }) { Cell({ renderedCellValue, row }) {
return ( return (
<Group gap="xs"> <Group gap="xs" wrap="nowrap">
<Avatar <Avatar
variant="outline" variant="outline"
radius="lg" radius="sm"
size="md" size="md"
styles={{ image: { objectFit: "contain" } }} styles={{ image: { objectFit: "contain" } }}
src={row.original.iconUrl} src={row.original.iconUrl}
> >
{row.original.name.at(0)?.toUpperCase()} {row.original.name.at(0)?.toUpperCase()}
</Avatar> </Avatar>
<Text>{renderedCellValue}</Text> <Text truncate="end" maw={500}>
{renderedCellValue}
</Text>
</Group> </Group>
); );
}, },
@@ -76,11 +84,11 @@ const createColumns = (
maxSize: 200, maxSize: 200,
Cell({ renderedCellValue, cell }) { Cell({ renderedCellValue, cell }) {
return ( return (
<Box maw={200}> <Tooltip label={cell.row.original.image} multiline maw={400}>
<Text truncate="end" title={cell.row.original.image}> <Box maw={200}>
{renderedCellValue} <Text truncate="end">{renderedCellValue}</Text>
</Text> </Box>
</Box> </Tooltip>
); );
}, },
}, },
@@ -95,13 +103,57 @@ const createColumns = (
); );
}, },
}, },
{
id: "cpuUsage",
accessorKey: "cpuUsage",
size: 120,
header: t("docker.field.stats.cpu.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const cpuUsageA = safeValue(rowA.original.cpuUsage);
const cpuUsageB = safeValue(rowB.original.cpuUsage);
return cpuUsageA - cpuUsageB;
},
Cell({ row }) {
const cpuUsage = safeValue(row.original.cpuUsage);
return (
<Text size="sm" c={cpuUsageColor(cpuUsage, row.original.state)}>
{cpuUsage.toFixed(2)}%
</Text>
);
},
},
{
id: "memoryUsage",
accessorKey: "memoryUsage",
size: 140,
header: t("docker.field.stats.memory.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const memoryUsageA = safeValue(rowA.original.memoryUsage);
const memoryUsageB = safeValue(rowB.original.memoryUsage);
return memoryUsageA - memoryUsageB;
},
Cell({ row }) {
const bytesUsage = safeValue(row.original.memoryUsage);
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
</Text>
);
},
},
]; ];
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) { export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n(); const t = useI18n();
const tDocker = useScopedI18n("docker"); const tDocker = useScopedI18n("docker");
const { data } = clientApi.docker.getContainers.useQuery(undefined, { const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData, initialData: { containers, timestamp },
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
@@ -131,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
enableColumnFilters: false, enableColumnFilters: false,
enablePagination: false, enablePagination: false,
enableRowSelection: true, enableRowSelection: true,
enableRowActions: true,
positionActionsColumn: "last",
positionToolbarAlertBanner: "top", positionToolbarAlertBanner: "top",
enableTableFooter: false, enableTableFooter: false,
enableBottomToolbar: false, enableBottomToolbar: false,
@@ -141,7 +195,10 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
autoFocus: true, autoFocus: true,
}, },
initialState: { density: "xs", showGlobalFilter: true }, initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
renderTopToolbarCustomActions: () => ( renderTopToolbarCustomActions: () => (
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}> <Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
{tDocker("action.refresh.label")} {tDocker("action.refresh.label")}
@@ -174,28 +231,104 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
); );
} }
const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [
{ action: "start", icon: IconPlayerPlay, color: "green" },
{ action: "stop", icon: IconPlayerStop, color: "red" },
{ action: "restart", icon: IconRotateClockwise, color: "orange" },
{ action: "remove", icon: IconTrash, color: "red" },
];
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
const t = useScopedI18n("docker.action");
const router = useRouter();
const utils = clientApi.useUtils();
const { openModal } = useModalAction(AddDockerAppToHomarr);
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
clientApi.docker[`${action}All`].useMutation({
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`${action}.notification.error.title`),
message: t(`${action}.notification.error.message`),
});
},
});
const mutations = {
start: useContainerAction("start"),
stop: useContainerAction("stop"),
restart: useContainerAction("restart"),
remove: useContainerAction("remove"),
};
return (
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" onClick={(e) => e.stopPropagation()}>
<IconDots size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconFileText size="1rem" />}
onClick={() => router.push(createContainerLogsPath(container))}
>
{t("logs.label")}
</Menu.Item>
<Menu.Divider />
{containerActions.map(({ action, icon: Icon, color }) => (
<Menu.Item
key={action}
leftSection={<Icon size="1rem" />}
color={color}
onClick={() => mutations[action].mutate({ ids: [container.id] })}
>
{t(`${action}.label`)}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
leftSection={<IconCategoryPlus size="1rem" />}
color="red"
onClick={() => openModal({ selectedContainers: [container] })}
>
{t("addToHomarr.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
interface ContainerActionBarProps { interface ContainerActionBarProps {
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"]; selectedContainers: DockerContainer[];
} }
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => { const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action"); const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr); const { openModal } = useModalAction(AddDockerAppToHomarr);
const handleClick = () => {
openModal({
selectedContainers,
});
};
const selectedIds = selectedContainers.map((container) => container.id); const selectedIds = selectedContainers.map((container) => container.id);
return ( return (
<Group gap="xs"> <Group gap="xs">
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} /> {containerActions.map(({ action, icon, color }) => (
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} /> <ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} /> ))}
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} /> <Button
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light"> leftSection={<IconCategoryPlus />}
color="red"
onClick={() => openModal({ selectedContainers })}
variant="light"
>
{t("addToHomarr.label")} {t("addToHomarr.label")}
</Button> </Button>
</Group> </Group>
@@ -0,0 +1,10 @@
"use client";
import dynamic from "next/dynamic";
export const ClientSideDockerLogsTerminal = dynamic(
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
{
ssr: false,
},
);
@@ -0,0 +1,47 @@
import { notFound } from "next/navigation";
import { Box, Group } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import "@xterm/xterm/css/xterm.css";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { ClientSideDockerLogsTerminal } from "./client";
interface DockerContainerLogsPageProps {
params: Promise<{ containerId: string }>;
searchParams: Promise<{ name?: string }>;
}
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) {
notFound();
}
const { containerId } = await params;
const { name } = await searchParams;
try {
await api.docker.logs({ id: containerId, tail: 1 });
} catch {
notFound();
}
return (
<>
<Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb
dynamicMappings={new Map([[containerId, name ?? containerId]])}
nonInteractable={[containerId]}
/>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal containerId={containerId} />
</Box>
</>
);
}
@@ -0,0 +1,3 @@
.outerTerminal > div {
height: 100%;
}
@@ -0,0 +1,80 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { clientApi } from "@homarr/api/client";
import classes from "./terminal.module.css";
interface DockerLogsTerminalProps {
containerId: string;
}
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
const ref = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const pendingLogsRef = useRef<string[]>([]);
const isTerminalReadyRef = useRef(false);
const writeToTerminal = useCallback((data: string) => {
if (!isTerminalReadyRef.current || !terminalRef.current) {
pendingLogsRef.current.push(data);
return;
}
terminalRef.current.write(data);
terminalRef.current.refresh(0, terminalRef.current.rows - 1);
}, []);
clientApi.docker.subscribeLogs.useSubscription(
{ id: containerId, tail: 200 },
{
onData: writeToTerminal,
onError(err) {
writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`);
},
},
);
useEffect(() => {
if (!ref.current) {
return () => undefined;
}
const fitAddon = new FitAddon();
const terminal = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 14,
});
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
isTerminalReadyRef.current = true;
pendingLogsRef.current.forEach((data) => terminal.write(data));
pendingLogsRef.current = [];
terminal.refresh(0, terminal.rows - 1);
const fitTimeout = setTimeout(() => fitAddon.fit());
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(ref.current);
return () => {
clearTimeout(fitTimeout);
resizeObserver.disconnect();
isTerminalReadyRef.current = false;
pendingLogsRef.current = [];
fitAddon.dispose();
terminal.dispose();
terminalRef.current = null;
};
}, [writeToTerminal]);
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
};
@@ -0,0 +1,124 @@
"use client";
import { Avatar, Badge, Button, Card, Group, Stack, Table, Text, Title } from "@mantine/core";
import { IconRefresh, IconSearch } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export const DockerDiscoveryPanel = () => {
const tDiscovery = useScopedI18n("docker.discovery" as never) as unknown as (
key: string,
values?: Record<string, unknown>,
) => string;
const { data: preview, refetch, isLoading: isLoadingPreview, isFetched } =
clientApi.docker.getDiscoveredPreview.useQuery(undefined, {
enabled: false,
});
const { mutate: syncDiscovery, isPending: isSyncing } = clientApi.docker.syncDiscovery.useMutation({
onSuccess(result) {
showSuccessNotification({
title: tDiscovery("sync.notification.success.title"),
message: tDiscovery("sync.notification.success.message", {
created: result.created,
updated: result.updated,
}),
});
void refetch();
},
onError() {
showErrorNotification({
title: tDiscovery("sync.notification.error.title"),
message: tDiscovery("sync.notification.error.message"),
});
},
});
const services = preview ?? [];
return (
<Card withBorder>
<Stack>
<Group justify="space-between">
<Title order={3}>{tDiscovery("title")}</Title>
<Group gap="xs">
<Button
variant="light"
leftSection={<IconSearch size={16} />}
loading={isLoadingPreview}
onClick={() => refetch()}
>
{tDiscovery("preview.label")}
</Button>
<Button
leftSection={<IconRefresh size={16} />}
loading={isSyncing}
onClick={() => syncDiscovery()}
disabled={services.length === 0}
>
{tDiscovery("sync.label")}
</Button>
</Group>
</Group>
{!isFetched ? (
<Text c="dimmed" ta="center" py="xl">
{tDiscovery("preview.idle")}
</Text>
) : services.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{tDiscovery("preview.empty")}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{tDiscovery("field.name")}</Table.Th>
<Table.Th>{tDiscovery("field.group")}</Table.Th>
<Table.Th>{tDiscovery("field.href")}</Table.Th>
<Table.Th>{tDiscovery("field.host")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{services.map((service) => (
<Table.Tr key={`${service.host}/${service.containerId}`}>
<Table.Td>
<Group gap="xs">
{service.icon && (
<Avatar size="sm" radius="sm" src={service.icon}>
{service.name.charAt(0)}
</Avatar>
)}
<Text size="sm">{service.name}</Text>
{service.integrationKind && (
<Badge size="xs" variant="light">
{service.integrationKind}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{service.group}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={1}>
{service.href}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{service.host}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
);
};
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { Alert, Button, Card, Group, List, Loader, Popover, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconDatabaseExport } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigSummaryList } from "~/components/config-import/config-summary-list";
const exportWarningPointKeys = [
"management.page.importExport.export.warning.message",
"management.page.importExport.export.warning.storage",
"management.page.importExport.export.warning.sharing",
] as const;
export const ExportConfigCard = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const { mutateAsync, isPending } = clientApi.config.exportFull.useMutation();
const [popoverOpened, setPopoverOpened] = useState(false);
const { data: preview, isLoading: previewLoading } = clientApi.config.previewExport.useQuery(undefined, {
enabled: popoverOpened,
});
const closePopover = () => setPopoverOpened(false);
const runExport = async () => {
await mutateAsync(undefined, {
onSuccess({ content, filename }) {
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
showSuccessNotification({
title: t("management.page.importExport.export.notification.success.title"),
message: t("management.page.importExport.export.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.export.notification.error.title"),
message: t("management.page.importExport.export.notification.error.message"),
});
},
});
};
const handleConfirmExport = () => {
closePopover();
void runExport();
};
return (
<Card withBorder>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.export.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.export.description")}
</Text>
</Stack>
<Popover
opened={popoverOpened}
onChange={setPopoverOpened}
width={420}
position="bottom-end"
withArrow
shadow="md"
>
<Popover.Target>
<Group justify="flex-end">
<Button
leftSection={<IconDatabaseExport size="1rem" />}
loading={isPending}
disabled={isPending}
onClick={() => setPopoverOpened(true)}
>
{t("management.page.importExport.export.action")}
</Button>
</Group>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="md">
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size="1.25rem" stroke={1.5} />}
title={t("management.page.importExport.export.warning.title")}
>
<List size="sm" spacing="xs">
{exportWarningPointKeys.map((key) => (
<List.Item key={key}>{t(key)}</List.Item>
))}
</List>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.summary.title")}
</Text>
{previewLoading && (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
{t("management.page.importExport.preview.exportLoading")}
</Text>
</Group>
)}
{preview && <ConfigSummaryList counts={preview} t={t} />}
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closePopover}>
{t("common.action.cancel")}
</Button>
<Button
color="red.9"
onClick={handleConfirmExport}
disabled={previewLoading}
loading={isPending}
>
{t("common.action.continue")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
</Stack>
</Card>
);
};
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, Button, Card, FileInput, Group, List, Stack, Text, Title } from "@mantine/core";
import { IconCheck, IconFileUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
export const ImportConfigCard = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const [file, setFile] = useState<File | null>(null);
const [fileContent, setFileContent] = useState("");
const [importResult, setImportResult] = useState<{
boards: number;
apps: number;
integrations: number;
groups: number;
searchEngines: number;
warnings: string[];
} | null>(null);
const {
mutate: runPreview,
data: preview,
isPending: previewLoading,
reset: resetPreview,
} = clientApi.config.previewImport.useMutation();
const { mutateAsync, isPending } = clientApi.config.importFull.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage");
},
});
useEffect(() => {
if (!fileContent) {
resetPreview();
return;
}
runPreview({ content: fileContent });
}, [fileContent, runPreview, resetPreview]);
const importAllowed = preview?.compatibility.status === "compatible";
const handleImport = async () => {
if (!file || !importAllowed) {
return;
}
const content = await file.text();
await mutateAsync(
{ content },
{
onSuccess(result) {
setImportResult(result);
showSuccessNotification({
title: t("management.page.importExport.importConfig.notification.success.title"),
message: t("management.page.importExport.importConfig.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.importConfig.notification.error.title"),
message: t("management.page.importExport.importConfig.notification.error.message"),
});
},
},
);
};
return (
<Card withBorder>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.importConfig.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.importConfig.description")}
</Text>
</Stack>
<FileInput
rightSection={<IconFileUpload />}
accept="application/json"
value={file}
onChange={(newFile) => {
setFile(newFile);
setImportResult(null);
setFileContent("");
if (!newFile) {
return;
}
void newFile.text().then((content) => {
setFileContent(content);
});
}}
label={t("management.page.importExport.importConfig.fileLabel")}
/>
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
{importResult && (
<Alert color="green" icon={<IconCheck />}>
<List size="sm">
<List.Item>{t("management.page.importExport.importConfig.result.boardsImported", { count: importResult.boards })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.appsCreated", { count: importResult.apps })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.integrationsCreated", { count: importResult.integrations })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.groupsCreated", { count: importResult.groups })}</List.Item>
</List>
</Alert>
)}
<Group justify="end">
<Button loading={isPending} disabled={!file || !importAllowed || previewLoading} onClick={() => void handleImport()}>
{t("common.action.import")}
</Button>
</Group>
</Stack>
</Card>
);
};
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { Button, Card, Code, FileInput, Group, Stack, Switch, Text, TextInput, Title } from "@mantine/core";
import { IconFileUpload } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import {
extractHomepageEnvVariables,
parseServicesYaml,
replaceHomepageEnvVariables,
} from "@homarr/board-portability/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { boardNameSchema } from "@homarr/validation/board";
export const ImportHomepageCard = () => {
const t = useI18n() as unknown as (key: string) => string;
const [fileValid, setFileValid] = useState(true);
const [rawContent, setRawContent] = useState("");
const [envVars, setEnvVars] = useState<string[]>([]);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const form = useZodForm(
z.object({
file: z.file(),
boardName: boardNameSchema,
createIntegrations: z.boolean(),
}),
{
mode: "controlled",
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
boardName: "",
createIntegrations: true,
},
onValuesChange(values, previous) {
void (async () => {
if (values.file === previous.file) return;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!values.file) return;
const content = await values.file.text();
setRawContent(content);
const vars = extractHomepageEnvVariables(content);
setEnvVars(vars);
setEnvValues(Object.fromEntries(vars.map((v: string) => [v, ""])));
const result = parseServicesYaml(content);
if (!result.success) {
setFileValid(false);
return;
}
setFileValid(true);
const suggestedName =
result.services[0]?.group.replaceAll(/[^A-Za-z0-9-_]/g, "-").replaceAll(/-+/g, "-") ?? "homepage";
if (form.values.boardName.trim().length === 0) {
form.setFieldValue("boardName", suggestedName.slice(0, 255));
}
})();
},
},
);
const { mutateAsync, isPending } = clientApi.board.importHomepageServices.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/boards");
},
});
const allEnvVarsFilled = envVars.length === 0 || envVars.every((v) => (envValues[v] ?? "").trim().length > 0);
return (
<Card withBorder>
<form
onSubmit={form.onSubmit((values) => {
if (!fileValid || !allEnvVarsFilled) return;
void (async () => {
const resolvedContent =
envVars.length > 0 ? replaceHomepageEnvVariables(rawContent, envValues) : rawContent;
await mutateAsync(
{
content: resolvedContent,
boardName: values.boardName,
createIntegrations: values.createIntegrations,
},
{
onSuccess() {
showSuccessNotification({
title: t("management.page.importExport.importHomepage.notification.success.title"),
message: t("management.page.importExport.importHomepage.notification.success.message"),
});
form.reset();
setRawContent("");
setEnvVars([]);
setEnvValues({});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.importHomepage.notification.error.title"),
message: t("management.page.importExport.importHomepage.notification.error.message"),
});
},
},
);
})();
})}
>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.importHomepage.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.importHomepage.description")}
</Text>
</Stack>
<FileInput
rightSection={<IconFileUpload />}
withAsterisk
accept=".yaml,.yml"
{...form.getInputProps("file")}
error={
(form.getInputProps("file").error as string | undefined) ??
(!fileValid && form.isDirty("file") ? t("management.page.importExport.importHomepage.invalidFile") : undefined)
}
type="button"
label={t("management.page.importExport.importHomepage.fileLabel")}
/>
{envVars.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.importHomepage.envVariables.label")}
</Text>
<Text size="xs" c="dimmed">
{t("management.page.importExport.importHomepage.envVariables.description")}
</Text>
{envVars.map((varName) => (
<TextInput
key={varName}
label={<Code>{`{{${varName}}}`}</Code>}
placeholder={varName.replace("HOMEPAGE_VAR_", "").toLowerCase().replaceAll("_", ".")}
value={envValues[varName] ?? ""}
onChange={(event) => {
setEnvValues((prev) => ({ ...prev, [varName]: event.target.value }));
}}
withAsterisk
/>
))}
</Stack>
)}
<TextInput
withAsterisk
label={t("management.page.importExport.importHomepage.boardNameLabel")}
{...form.getInputProps("boardName")}
/>
<Switch
label={t("management.page.importExport.importHomepage.createIntegrations")}
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending} disabled={!allEnvVarsFilled}>
{t("common.action.import")}
</Button>
</Group>
</Stack>
</form>
</Card>
);
};
@@ -0,0 +1,34 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { DockerDiscoveryPanel } from "./docker-discovery-panel";
import { ExportConfigCard } from "./export-config-card";
import { ImportConfigCard } from "./import-config-card";
import { ImportHomepageCard } from "./import-homepage-card";
export default async function ImportExportPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const t = await getScopedI18n("management.page.importExport" as never) as unknown as (key: string) => string;
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{t("title")}</Title>
<ExportConfigCard />
<ImportConfigCard />
<ImportHomepageCard />
{env.ENABLE_DOCKER && <DockerDiscoveryPanel />}
</Stack>
</>
);
}
@@ -0,0 +1,29 @@
"use client";
import { Group, Slider, Text } from "@mantine/core";
import { IconTextSize } from "@tabler/icons-react";
import { useLogContext } from "./log-context";
export const LogFontSizeSlider = () => {
const { fontSize, setFontSize } = useLogContext();
return (
<Group gap="xs" wrap="nowrap" w={160}>
<IconTextSize size={18} style={{ flexShrink: 0 }} />
<Slider
min={10}
max={24}
step={1}
value={fontSize}
onChange={setFontSize}
label={(val) => `${val}px`}
style={{ flex: 1 }}
size="xs"
/>
<Text size="xs" c="dimmed" w={32} ta="right">
{fontSize}px
</Text>
</Group>
);
};
@@ -6,10 +6,23 @@ import { createContext, useContext, useMemo, useState } from "react";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { logLevels } from "@homarr/core/infrastructure/logs/constants"; import { logLevels } from "@homarr/core/infrastructure/logs/constants";
const FONT_SIZE_KEY = "homarr-log-font-size";
const DEFAULT_FONT_SIZE = 14;
const readStoredFontSize = () => {
if (typeof window === "undefined") return DEFAULT_FONT_SIZE;
const stored = localStorage.getItem(FONT_SIZE_KEY);
if (!stored) return DEFAULT_FONT_SIZE;
const parsed = Number(stored);
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 10), 24) : DEFAULT_FONT_SIZE;
};
const LogContext = createContext<{ const LogContext = createContext<{
level: LogLevel; level: LogLevel;
setLevel: (level: LogLevel) => void; setLevel: (level: LogLevel) => void;
activeLevels: LogLevel[]; activeLevels: LogLevel[];
fontSize: number;
setFontSize: (size: number) => void;
} | null>(null); } | null>(null);
interface LogContextProviderProps extends PropsWithChildren { interface LogContextProviderProps extends PropsWithChildren {
@@ -18,9 +31,19 @@ interface LogContextProviderProps extends PropsWithChildren {
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => { export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
const [level, setLevel] = useState(defaultLevel); const [level, setLevel] = useState(defaultLevel);
const [fontSize, setFontSizeRaw] = useState(readStoredFontSize);
const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]); const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]);
return <LogContext.Provider value={{ level, setLevel, activeLevels }}>{children}</LogContext.Provider>; const setFontSize = (size: number) => {
setFontSizeRaw(size);
localStorage.setItem(FONT_SIZE_KEY, String(size));
};
return (
<LogContext.Provider value={{ level, setLevel, activeLevels, fontSize, setFontSize }}>
{children}
</LogContext.Provider>
);
}; };
export const useLogContext = () => { export const useLogContext = () => {
@@ -13,6 +13,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { ClientSideTerminalComponent } from "./client"; import { ClientSideTerminalComponent } from "./client";
import { LogFontSizeSlider } from "./font-size-slider";
import { LogLevelSelection } from "./level-selection"; import { LogLevelSelection } from "./level-selection";
import { LogContextProvider } from "./log-context"; import { LogContextProvider } from "./log-context";
@@ -38,7 +39,10 @@ export default async function LogsManagementPage() {
<LogContextProvider defaultLevel={logsEnv.LEVEL}> <LogContextProvider defaultLevel={logsEnv.LEVEL}>
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<LogLevelSelection /> <Group gap="md" wrap="nowrap">
<LogFontSizeSlider />
<LogLevelSelection />
</Group>
</Group> </Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black"> <Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent /> <ClientSideTerminalComponent />
@@ -2,7 +2,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
@@ -13,9 +12,11 @@ import classes from "./terminal.module.css";
export const TerminalComponent = () => { export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { activeLevels } = useLogContext(); const { activeLevels, fontSize } = useLogContext();
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const terminalRef = useRef<Terminal>(null);
clientApi.log.subscribe.useSubscription( clientApi.log.subscribe.useSubscription(
{ {
levels: activeLevels, levels: activeLevels,
@@ -26,7 +27,6 @@ export const TerminalComponent = () => {
terminalRef.current?.refresh(0, terminalRef.current.rows - 1); terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
}, },
onError(err) { onError(err) {
// This makes sense as logging might cause an infinite loop
alert(err); alert(err);
}, },
}, },
@@ -37,28 +37,39 @@ export const TerminalComponent = () => {
return () => undefined; return () => undefined;
} }
const canvasAddon = new CanvasAddon(); const fitAddon = new FitAddon();
const terminal = new Terminal({
terminalRef.current = new Terminal({
cursorBlink: false, cursorBlink: false,
disableStdin: true, disableStdin: true,
convertEol: true, convertEol: true,
fontSize,
}); });
terminalRef.current.open(ref.current); terminal.open(ref.current);
terminalRef.current.loadAddon(canvasAddon); terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
// This is a hack to make sure the terminal is rendered before we try to fit it const fitTerminal = () => fitAddon.fit();
// You can blame @Meierschlumpf for this const fitTimeout = setTimeout(fitTerminal);
setTimeout(() => {
const fitAddon = new FitAddon(); const resizeObserver = new ResizeObserver(fitTerminal);
terminalRef.current?.loadAddon(fitAddon); resizeObserver.observe(ref.current);
fitAddon.fit();
});
return () => { return () => {
terminalRef.current?.dispose(); clearTimeout(fitTimeout);
canvasAddon.dispose(); resizeObserver.disconnect();
fitAddon.dispose();
terminal.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
}; };
}, []); }, []);
useEffect(() => {
if (!terminalRef.current) return;
terminalRef.current.options.fontSize = fontSize;
fitAddonRef.current?.fit();
}, [fontSize]);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>; return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
}; };
@@ -0,0 +1,53 @@
"use client";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export const ResetTours = () => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
onSuccess() {
showSuccessNotification({
title: t("resetSuccess.title"),
message: t("resetSuccess.message"),
});
void utils.user.getTourStatus.invalidate();
},
});
const tours = [
{ key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
{ key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
];
return (
<Stack gap="xs">
<Group gap="xs">
{tours.map(({ key, completed }) => (
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
</Badge>
))}
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Button
leftSection={<IconRefresh size={16} />}
variant="light"
size="sm"
w="fit-content"
onClick={() => resetTours()}
loading={isPending}
>
{t("reset")}
</Button>
</Stack>
);
};
@@ -13,6 +13,7 @@ import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button"; import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form"; import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { ResetTours } from "./_components/_reset-tours";
interface Props { interface Props {
params: Promise<{ params: Promise<{
@@ -87,6 +88,13 @@ export default async function EditUserPage(props: Props) {
</Box> </Box>
</Group> </Group>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
<ResetTours />
</Stack>
)}
<DangerZoneRoot> <DangerZoneRoot>
<DangerZoneItem <DangerZoneItem
label={t("user.action.delete.label")} label={t("user.action.delete.label")}
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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 { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access"; import { canAccessUserEditPage } from "./access";
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
const session = await auth(); const session = await auth();
const t = await getI18n(); const t = await getI18n();
const tUser = await getScopedI18n("management.page.user"); 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)) { if (!canAccessUserEditPage(session, user.id)) {
notFound(); notFound();
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server"; 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 { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form"; import { ChangePasswordForm } from "./_components/_change-password-form";
@@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) {
.getById({ .getById({
userId: params.userId, userId: params.userId,
}) })
.catch(catchTrpcNotFound); .catch(catchTrpcNotFound)
.catch(catchTrpcUnauthorized);
if (!canAccessUserEditPage(session, user.id)) { if (!canAccessUserEditPage(session, user.id)) {
notFound(); notFound();
@@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
@@ -38,14 +39,18 @@ export default async function UsersPage() {
title={t("list.title")} title={t("list.title")}
primaryAction={ primaryAction={
credentialsProviderEnabled ? ( credentialsProviderEnabled ? (
<MobileAffixButton component={Link} href="/manage/users/create"> <TourTarget id="manage-users-create">
{t("create.title")} <MobileAffixButton component={Link} href="/manage/users/create">
</MobileAffixButton> {t("create.title")}
</MobileAffixButton>
</TourTarget>
) : undefined ) : undefined
} }
floatingPrimaryAction={credentialsProviderEnabled} floatingPrimaryAction={credentialsProviderEnabled}
> >
<UserListComponent initialUserList={userList} /> <TourTarget id="manage-users-list">
<UserListComponent initialUserList={userList} />
</TourTarget>
</ManagePageLayout> </ManagePageLayout>
); );
} }
@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in currentDefinition,
settings, settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
{ {
title(t) { title(t) {
@@ -1,11 +1,11 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { Button, Grid, Group, NumberInput, Stack } from "@mantine/core"; import { Grid, NumberInput, Stack } from "@mantine/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import type { GridStack } from "@homarr/gridstack"; import type { GridStack } from "@homarr/gridstack";
import { createModal } from "@homarr/modals"; import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
@@ -16,9 +16,7 @@ interface InnerProps {
} }
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => { export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tCommon = useScopedI18n("common");
const t = useI18n(); const t = useI18n();
// Keep track of the maximum width based on the x offset
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
const form = useZodForm( const form = useZodForm(
z.object({ z.object({
@@ -38,7 +36,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
height: innerProps.item.height, height: innerProps.item.height,
}, },
onValuesChange(values, previous) { onValuesChange(values, previous) {
// Update the maximum width when the x offset changes
if (values.xOffset !== previous.xOffset) { if (values.xOffset !== previous.xOffset) {
maxWidthRef.current = innerProps.columnCount - values.xOffset; maxWidthRef.current = innerProps.columnCount - values.xOffset;
} }
@@ -70,6 +67,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
<Grid.Col span={{ base: 12, md: 6 }}> <Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput <NumberInput
label={t("item.moveResize.field.xOffset.label")} label={t("item.moveResize.field.xOffset.label")}
data-autofocus
min={0} min={0}
max={innerProps.columnCount - 1} max={innerProps.columnCount - 1}
{...form.getInputProps("xOffset")} {...form.getInputProps("xOffset")}
@@ -93,12 +91,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} /> <NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<Group justify="end"> <ModalFormFooter onCancel={actions.closeModal} />
<Button variant="subtle" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit">{tCommon("action.saveChanges")}</Button>
</Group>
</Stack> </Stack>
</form> </form>
); );
@@ -106,5 +99,5 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
defaultTitle(t) { defaultTitle(t) {
return t("item.moveResize.title"); return t("item.moveResize.title");
}, },
size: "lg", size: modalSizeForm,
}); });
@@ -1,14 +1,14 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core"; import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createId, objectEntries } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals"; import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -86,32 +86,31 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
}; };
return ( return (
<Stack> <SelectGridLayout
<Input search={search}
value={search} onSearchChange={setSearch}
onChange={(event) => setSearch(event.currentTarget.value)} placeholder={`${t("item.create.search")}...`}
leftSection={<IconSearch />} onSearchKeyDown={(event) => {
placeholder={`${t("item.create.search")}...`} if (event.key === "Enter" && filteredItems.length === 1) {
data-autofocus // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
onKeyDown={(event) => { handleAdd(filteredItems[0]!.kind);
// Add item if there is only one item in the list and user presses Enter }
if (event.key === "Enter" && filteredItems.length === 1) { }}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion >
handleAdd(filteredItems[0]!.kind); {filteredItems.map((item) => (
} <WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
}} ))}
/>
<Grid> {filteredItems.length === 0 && (
{filteredItems.map((item) => ( <Center p="xl">
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} /> <Text c="dimmed">{t("common.noResults")}</Text>
))} </Center>
</Grid> )}
</Stack> </SelectGridLayout>
); );
}).withOptions({ }).withOptions({
defaultTitle: (t) => t("item.create.title"), defaultTitle: (t) => t("item.create.title"),
size: "xl", size: modalSizeSelect,
}); });
const WidgetItem = ({ const WidgetItem = ({
@@ -130,46 +129,58 @@ const WidgetItem = ({
const t = useI18n(); const t = useI18n();
return ( return (
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}> <Card
<Card h="100%"> h={selectGridCardHeight}
<Stack justify="space-between" h="100%"> withBorder
<Stack gap="xs"> pos="relative"
<Center> style={{ overflow: "hidden", "--_hover-opacity": "0" }}
<item.icon /> onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
</Center> onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center"> >
{item.name} <Stack h="100%" gap="xs">
</Text> <Group gap="sm" wrap="nowrap" align="flex-start">
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed"> <item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
{item.description} <Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
</Text> {item.name}
</Stack> </Text>
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} /> </Group>
<Button onClick={onSelect} variant="light" size="xs" fullWidth> <Tooltip label={item.description} multiline w={250} disabled={!item.description}>
{t(`item.create.addToBoard`)} <Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
</Button> {item.description}
</Stack> </Text>
</Card> </Tooltip>
</Grid.Col> <SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
{t(`item.create.addToBoard`)}
</Button>
</Box>
</Card>
); );
}; };
interface SupportedIntegrationsProps { const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
integrations: IntegrationKind[];
mt: string;
}
const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) => {
if (integrations.length === 0) { if (integrations.length === 0) {
return null; return null;
} }
// When there are 8 or more integrations, we show 6 and a "+X" avatar. Otherwise, we show all integrations.
const countToShow = integrations.length >= 8 ? 6 : 7; const countToShow = integrations.length >= 8 ? 6 : 7;
const moreCount = integrations.length - countToShow; const moreCount = integrations.length - countToShow;
return ( return (
<Center mt={mt}> <Group gap={2} mt="auto">
<Tooltip.Group closeDelay={100}> <Tooltip.Group closeDelay={100}>
<Group gap={2}> <Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => ( {integrations.slice(0, countToShow).map((integration) => (
@@ -195,6 +206,6 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps)
)} )}
</Group> </Group>
</Tooltip.Group> </Tooltip.Group>
</Center> </Group>
); );
}; };
@@ -1,8 +1,8 @@
import { Button, Group, Stack, TextInput } from "@mantine/core"; import { Stack, TextInput } from "@mantine/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals"; import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface Category { interface Category {
@@ -36,13 +36,10 @@ export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps
> >
<Stack> <Stack>
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} /> <TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right"> <ModalFormFooter onCancel={actions.closeModal} submitLabel={innerProps.submitLabel} />
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit">{innerProps.submitLabel}</Button>
</Group>
</Stack> </Stack>
</form> </form>
); );
}).withOptions({}); }).withOptions({
size: modalSizeForm,
});
@@ -1,10 +1,10 @@
"use client"; "use client";
import { Button, CloseButton, ColorInput, Group, Stack, TextInput, useMantineTheme } from "@mantine/core"; import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals"; import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { TextMultiSelect } from "@homarr/ui"; import { TextMultiSelect } from "@homarr/ui";
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared"; import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
@@ -31,7 +31,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
})} })}
> >
<Stack> <Stack>
<TextInput label={t("section.dynamic.option.title.label")} {...form.getInputProps("title")} /> <TextInput label={t("section.dynamic.option.title.label")} data-autofocus {...form.getInputProps("title")} />
<TextMultiSelect <TextMultiSelect
label={t("section.dynamic.option.customCssClasses.label")} label={t("section.dynamic.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")} {...form.getInputProps("customCssClasses")}
@@ -48,14 +48,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
} }
{...form.getInputProps("borderColor")} {...form.getInputProps("borderColor")}
/> />
<Group justify="end"> <ModalFormFooter onCancel={actions.closeModal} />
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.saveChanges")}</Button>
</Group>
</Group>
</Stack> </Stack>
</form> </form>
); );
@@ -63,5 +56,5 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
size: "lg", size: modalSizeForm,
}); });
@@ -0,0 +1,138 @@
"use client";
import { Alert, Group, List, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { ConfigImportPreview } from "@homarr/board-portability";
import { ConfigSummaryList } from "./config-summary-list";
const compatibilityAlertColor: Record<ConfigImportPreview["compatibility"]["status"], string> = {
compatible: "blue",
unsupportedVersion: "red",
invalidStructure: "red",
};
type CountSection = { labelKey: string; entries: { key: string; count: number }[] };
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const buildCountSections = (preview: ConfigImportPreview): CountSection[] => [
{
labelKey: "management.page.importExport.preview.importWillReuse",
entries: Object.entries(preview.toReuse)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.reuse${capitalize(key)}`, count })),
},
{
labelKey: "management.page.importExport.preview.importWillSkip",
entries: Object.entries(preview.toSkip)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.skip${capitalize(key)}`, count })),
},
{
labelKey: "management.page.importExport.preview.importWillUpdate",
entries: Object.entries(preview.toUpdate)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.update${capitalize(key)}`, count })),
},
];
type ConfigImportPreviewPanelProps = {
preview: ConfigImportPreview | undefined;
previewLoading: boolean;
t: (key: string, values?: Record<string, unknown>) => string;
};
export const ConfigImportPreviewPanel = ({ preview, previewLoading, t }: ConfigImportPreviewPanelProps) => {
if (!preview && !previewLoading) {
return null;
}
const importAllowed = preview?.compatibility.status === "compatible";
return (
<Stack gap="sm">
{previewLoading && (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
{t("management.page.importExport.preview.importLoading")}
</Text>
</Group>
)}
{preview && (
<>
<Alert
variant="light"
color={compatibilityAlertColor[preview.compatibility.status]}
icon={<IconAlertTriangle size="1rem" stroke={1.5} />}
title={
preview.compatibility.status === "compatible"
? t("management.page.importExport.preview.migrationNote", {
bundleVersion: preview.meta.bundleVersion || "?",
})
: t("management.page.importExport.preview.importIncompatible")
}
>
{preview.compatibility.status === "compatible" && preview.meta.exportedAt && (
<Text size="sm" mb="xs">
{t("management.page.importExport.preview.importMeta", {
exportedAt: preview.meta.exportedAt,
homarrVersion: preview.meta.homarrVersion,
bundleVersion: preview.meta.bundleVersion,
})}
</Text>
)}
{preview.compatibility.issues.length > 0 && (
<List size="sm" spacing={4}>
{preview.compatibility.issues.map((issue) => (
<List.Item key={issue}>{issue}</List.Item>
))}
</List>
)}
</Alert>
{importAllowed && (
<>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillCreate")}
</Text>
<ConfigSummaryList counts={preview.toCreate} t={t} />
</Stack>
{buildCountSections(preview)
.filter((section) => section.entries.length > 0)
.map((section) => (
<Stack gap="xs" key={section.labelKey}>
<Text size="sm" fw={500}>
{t(section.labelKey)}
</Text>
<List size="sm">
{section.entries.map((entry) => (
<List.Item key={entry.key}>
{t(entry.key, { count: entry.count })}
</List.Item>
))}
</List>
</Stack>
))}
{preview.warnings.length > 0 && (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="1rem" />}>
<List size="sm">
{preview.warnings.map((warning) => (
<List.Item key={warning}>{warning}</List.Item>
))}
</List>
</Alert>
)}
</>
)}
</>
)}
</Stack>
);
};
@@ -0,0 +1,35 @@
"use client";
import { List, Text } from "@mantine/core";
import type { ConfigEntityCounts, ConfigEntityKey } from "@homarr/board-portability";
import { CONFIG_ENTITY_KEYS } from "@homarr/board-portability";
const TRANSLATION_PREFIX = "management.page.importExport.summary";
type ConfigSummaryListProps = {
counts: ConfigEntityCounts;
t: (key: string, values?: Record<string, unknown>) => string;
};
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
const entries = CONFIG_ENTITY_KEYS.filter((key) => counts[key] > 0);
if (entries.length === 0) {
return (
<Text size="sm" c="dimmed">
{t(`${TRANSLATION_PREFIX}.empty`)}
</Text>
);
}
return (
<List size="sm" spacing={4}>
{entries.map((key: ConfigEntityKey) => (
<List.Item key={key}>
{t(`${TRANSLATION_PREFIX}.${key}`, { count: counts[key] })}
</List.Item>
))}
</List>
);
};
@@ -0,0 +1,95 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import {
featuredIntegrations,
getIntegrationName,
hiddenFromOnboarding,
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
const buildWidgetsByIntegration = () => {
const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
IntegrationKind,
WidgetKind[]
>;
for (const [widgetKind, widget] of objectEntries(widgetImports)) {
const supported =
"supportedIntegrations" in widget.definition
? (widget.definition.supportedIntegrations as IntegrationKind[])
: [];
for (const kind of supported) {
if (kind in map) {
map[kind].push(widgetKind);
}
}
}
return map;
};
export const widgetsByIntegration = buildWidgetsByIntegration();
export const categoryTranslationKeys: Record<string, string> = {
dnsHole: "integration.category.dnsHole",
mediaService: "integration.category.mediaService",
calendar: "integration.category.calendar",
mediaSearch: "integration.category.mediaSearch",
mediaRelease: "integration.category.mediaRelease",
mediaRequest: "integration.category.mediaRequest",
downloadClient: "integration.category.downloadClient",
usenet: "integration.category.usenet",
torrent: "integration.category.torrent",
miscellaneous: "integration.category.miscellaneous",
smartHomeServer: "integration.category.smartHomeServer",
indexerManager: "integration.category.indexerManager",
healthMonitoring: "integration.category.healthMonitoring",
search: "integration.category.search",
mediaTranscoding: "integration.category.mediaTranscoding",
networkController: "integration.category.networkController",
releasesProvider: "integration.category.releasesProvider",
notifications: "integration.category.notifications",
firewall: "integration.category.firewall",
timetable: "integration.category.timetable",
photoService: "integration.category.photoService",
notes: "integration.category.notes",
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
};
export interface IntegrationGridItem {
kind: IntegrationKind;
name: string;
categories: string[];
widgets: WidgetKind[];
}
export const buildSortedIntegrations = (
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
): IntegrationGridItem[] =>
integrationKinds
.filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
if (!options.enableMockIntegration && kind === "mock") return false;
return true;
})
.map((kind) => ({
kind,
name: getIntegrationName(kind),
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
widgets: widgetsByIntegration[kind],
}))
.sort((left, right) => {
const leftIdx = featuredIntegrations.indexOf(left.kind);
const rightIdx = featuredIntegrations.indexOf(right.kind);
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
if (leftIdx !== -1) return -1;
if (rightIdx !== -1) return 1;
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
});
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
@@ -0,0 +1,140 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
interface IntegrationMultiSelectGridProps {
selectedKinds: IntegrationKind[];
onSelectionChange: (kinds: IntegrationKind[]) => void;
enableMockIntegration?: boolean;
onboarding?: boolean;
detectedKinds?: Set<IntegrationKind>;
}
export const IntegrationMultiSelectGrid = ({
selectedKinds: selectedKindsArray,
onSelectionChange,
enableMockIntegration = false,
onboarding = false,
detectedKinds,
}: IntegrationMultiSelectGridProps) => {
const [search, setSearch] = useState("");
const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]);
const t = useI18n();
const toggleKind = useCallback(
(kind: IntegrationKind) => {
const current = new Set(selectedKindsArray);
if (current.has(kind)) {
current.delete(kind);
} else {
current.add(kind);
}
onSelectionChange(Array.from(current));
},
[selectedKindsArray, onSelectionChange],
);
const integrations = useMemo(
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
[enableMockIntegration, onboarding],
);
const sorted = useMemo(() => {
if (!detectedKinds || detectedKinds.size === 0) return integrations;
const detected = integrations.filter((i) => detectedKinds.has(i.kind));
const rest = integrations.filter((i) => !detectedKinds.has(i.kind));
return [...detected, ...rest];
}, [integrations, detectedKinds]);
const filtered = useMemo(() => filterIntegrations(sorted, search), [sorted, search]);
return (
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
disableScroll={onboarding}
disableAutoFocus={onboarding}
>
{filtered.map((integration) => {
const isSelected = selectedKinds.has(integration.kind);
const isDetected = detectedKinds?.has(integration.kind) ?? false;
return (
<Card
key={integration.kind}
h={selectGridCardHeight}
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
}}
onClick={() => toggleKind(integration.kind)}
withBorder
>
<Stack justify="space-between" h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2} style={{ flex: 1 }}>
{integration.name}
</Text>
{isSelected && <IconCheck size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0 }} />}
</Group>
<Stack gap={4} mt="auto">
{isDetected && (
<Badge variant="light" color="teal" size="xs" leftSection={<IconBrandDocker size={12} />}>
{t("integration.grid.detected")}
</Badge>
)}
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
</Text>
))}
</Stack>
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
{t("integration.grid.noWidgets")}
</Badge>
)}
</Stack>
</Stack>
</Card>
);
})}
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
);
};
@@ -0,0 +1,90 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconPuzzle } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
interface IntegrationSelectGridProps {
onSelect: (kind: IntegrationKind) => void;
enableMockIntegration?: boolean;
}
export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => {
const [search, setSearch] = useState("");
const t = useI18n();
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
>
{filtered.map((integration) => (
<Card
key={integration.kind}
h={selectGridCardHeight}
style={{ cursor: "pointer" }}
onClick={() => onSelect(integration.kind)}
withBorder
>
<Stack justify="space-between" h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
{integration.name}
</Text>
</Group>
<Stack gap={4} mt="auto">
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
</Text>
))}
</Stack>
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
{t("integration.grid.noWidgets")}
</Badge>
)}
</Stack>
</Stack>
</Card>
))}
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
);
};
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { Group, ScrollArea, Title } from "@mantine/core";
import { IconArrowLeft } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeSelect } from "@homarr/modals";
import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { IntegrationSelectGrid } from "./integration-select-grid";
interface IntegrationSelectModalProps {
onSuccess?: () => void;
enableMockIntegration?: boolean;
}
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const [step, setStep] = useState<"select" | "form">("select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
const handleSelect = (kind: IntegrationKind) => {
setSelectedKind(kind);
setStep("form");
};
const handleBack = () => {
setStep("select");
setSelectedKind(null);
};
const handleSuccess = () => {
innerProps.onSuccess?.();
actions.closeModal();
};
if (step === "form" && selectedKind) {
return (
<ScrollArea.Autosize mah="80vh">
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={handleBack}>
<IconArrowLeft size={18} />
<IntegrationAvatar kind={selectedKind} size="sm" />
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
</Group>
<NewIntegrationForm kind={selectedKind} onSuccess={handleSuccess} onCancel={handleBack} />
</ScrollArea.Autosize>
);
}
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} />;
}).withOptions({
defaultTitle: (t) => t("integration.action.create"),
size: modalSizeSelect,
});
+7 -2
View File
@@ -6,6 +6,7 @@ import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger"; import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search"; import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user"; import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo"; import { HomarrLogoWithTitle } from "./logo/homarr-logo";
@@ -25,11 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
{logo ?? <HomarrLogoWithTitle size="md" />} {logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton> </UnstyledButton>
</Group> </Group>
<DesktopSearchInput /> <TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap"> <Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions} {actions}
<MobileSearchButton /> <MobileSearchButton />
<UserButton /> <TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group> </Group>
</Group> </Group>
<Spotlight /> <Spotlight />
@@ -0,0 +1,11 @@
"use client";
import type { ReactNode } from "react";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box } from "@mantine/core";
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
<OnboardingTour.Target id={id}>
<Box data-tour-target={id}>{children}</Box>
</OnboardingTour.Target>
);
@@ -2,6 +2,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core"; import { NavLink } from "@mantine/core";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
@@ -9,9 +10,15 @@ import { Link } from "@homarr/ui";
export const CommonNavLink = (props: ClientNavigationLink) => export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />; "href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
if (!id) return <>{children}</>;
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
const NavLinkHref = (props: NavigationLinkHref) => { const NavLinkHref = (props: NavigationLinkHref) => {
const pathname = usePathname(); const pathname = usePathname();
return props.external ? ( const tourId = props["data-onboarding-tour-id"];
const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" /> <NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
) : ( ) : (
<NavLink <NavLink
@@ -19,26 +26,29 @@ const NavLinkHref = (props: NavigationLinkHref) => {
label={props.label} label={props.label}
leftSection={props.icon} leftSection={props.icon}
href={props.href} href={props.href}
active={pathname === props.href} active={pathname === props.href || pathname.startsWith(`${props.href}/`)}
/> />
); );
return <TourTarget id={tourId}>{link}</TourTarget>;
}; };
const NavLinkWithItems = (props: NavigationLinkWithItems) => { const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const pathname = usePathname(); const pathname = usePathname();
const isActive = props.items.some((item) => item.href === pathname); const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
return ( const nav = (
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}> <NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
{props.items.map((item) => ( {props.items.map((item) => (
<NavLinkHref key={item.label} {...item} /> <NavLinkHref key={item.label} {...item} />
))} ))}
</NavLink> </NavLink>
); );
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
}; };
interface CommonNavigationLinkProps { interface CommonNavigationLinkProps {
label: string; label: string;
icon: ReactNode; icon: ReactNode;
"data-onboarding-tour-id"?: string;
} }
interface NavigationLinkHref extends CommonNavigationLinkProps { interface NavigationLinkHref extends CommonNavigationLinkProps {
@@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
return null; return null;
} }
const { icon: TablerIcon, iconProps, ...props } = link; const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link;
const Icon = const Icon =
typeof TablerIcon === "string" ? ( typeof TablerIcon === "string" ? (
<Image src={TablerIcon} w={20} h={20} /> <Image src={TablerIcon} w={20} h={20} />
@@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
if ("items" in props) { if ("items" in props) {
clientLink = { clientLink = {
...props, ...props,
"data-onboarding-tour-id": tourId,
items: props.items items: props.items
.filter((item) => !item.hidden) .filter((item) => !item.hidden)
.map((item) => { .map((item) => {
const { "data-onboarding-tour-id": itemTourId, ...rest } = item;
return { return {
...item, ...rest,
"data-onboarding-tour-id": itemTourId,
icon: <item.icon size={20} stroke={1.5} {...iconProps} />, icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
}; };
}), }),
} as ClientNavigationLink; } as ClientNavigationLink;
} else { } else {
clientLink = props as ClientNavigationLink; clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink;
} }
return <CommonNavLink key={index} {...clientLink} icon={Icon} />; return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
})} })}
@@ -63,6 +66,7 @@ interface CommonNavigationLinkProps {
icon: TablerIcon | string; icon: TablerIcon | string;
iconProps?: TablerIconProps; iconProps?: TablerIconProps;
hidden?: boolean; hidden?: boolean;
"data-onboarding-tour-id"?: string;
} }
interface NavigationLinkHref extends CommonNavigationLinkProps { interface NavigationLinkHref extends CommonNavigationLinkProps {
@@ -58,19 +58,23 @@ export const DynamicBreadcrumb = ({
} }
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`; const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`; const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
const mappedValue = dynamicMappings?.get(pathnamePart);
if (mappedValue) {
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{mappedValue}</Text>;
}
return (
<Anchor key={href} href={href}>
{mappedValue}
</Anchor>
);
}
if (nonInteractable?.includes(pathnamePart)) { if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>; return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
} }
if (dynamicMappings?.has(pathnamePart)) {
return (
<Anchor key={href} href={href}>
{dynamicMappings.get(pathnamePart)}
</Anchor>
);
}
return ( return (
<Anchor key={href} href={href}> <Anchor key={href} href={href}>
{t(`${translationKey}.label` as TranslationKeys)} {t(`${translationKey}.label` as TranslationKeys)}
@@ -0,0 +1,108 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
export const BoardTourProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("onboardingTour.board");
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
const handleEnd = () => {
utils.user.getTourStatus.setData(undefined, {
completedManageTour: tourStatus?.completedManageTour ?? false,
completedBoardTour: true,
});
completeTour({ tour: "board" });
};
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [
{
id: "board-search",
title: t("welcome.title"),
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink(
"/docs/getting-started/after-the-installation",
"#creating-your-first-board",
)}
/>
),
},
{
id: "board-edit-mode",
title: t("editMode.title"),
requiresChangeAccess: true,
content: (
<TourStepContent
description={t("editMode.description")}
documentationHref={createDocumentationLink(
"/docs/getting-started/after-the-installation",
"#arrange-and-organize-your-board",
)}
/>
),
},
{
id: "board-settings",
title: t("settings.title"),
requiresChangeAccess: true,
content: (
<TourStepContent
description={t("settings.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
{
id: "board-switcher",
title: t("switcher.title"),
content: (
<TourStepContent
description={t("switcher.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
{
id: "board-user-menu",
title: t("userMenu.title"),
content: (
<TourStepContent
description={t("userMenu.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
/>
),
},
];
return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[];
}, [hasChangeAccess, t]);
return (
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
{children}
</TourShell>
);
};
@@ -0,0 +1,220 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { useMediaQuery } from "@mantine/hooks";
import {
IconAffiliateFilled,
IconAppsFilled,
IconLayoutDashboardFilled,
IconPlus,
IconUserFilled,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
interface ManageTourProviderProps extends PropsWithChildren {
isAdmin: boolean;
}
const stepRoutes: Record<string, string> = {
"manage-welcome": "/manage",
"manage-boards-list": "/manage/boards",
"manage-boards-create": "/manage/boards",
"manage-apps-list": "/manage/apps",
"manage-apps-create": "/manage/apps",
"manage-integrations-list": "/manage/integrations",
"manage-integrations-create": "/manage/integrations",
"manage-users-list": "/manage/users",
"manage-users-create": "/manage/users",
};
const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
withReveal: false,
popoverProps: {
position: "bottom",
middlewares: { shift: { padding: 16 }, flip: true },
},
};
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const t = useScopedI18n("onboardingTour.manage");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const isMobile = useMediaQuery("(max-width: 48em)");
const pathname = usePathname();
const router = useRouter();
const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname);
const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname);
const [tourActive, setTourActive] = useState(false);
const [tourDismissed, setTourDismissed] = useState(false);
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
useEffect(() => {
if (tourDismissed) return;
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
setTourActive(true);
}
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
useEffect(() => {
if (!isManageSection) {
setTourActive(false);
}
}, [isManageSection]);
useEffect(() => {
const wasCompleted = wasManageTourCompletedRef.current;
const isCompleted = tourStatus?.completedManageTour;
if (wasCompleted === true && isCompleted === false) {
setTourDismissed(false);
}
wasManageTourCompletedRef.current = isCompleted;
}, [tourStatus?.completedManageTour]);
const handleEnd = useCallback(() => {
setTourDismissed(true);
setTourActive(false);
utils.user.getTourStatus.setData(undefined, {
completedManageTour: true,
completedBoardTour: tourStatus?.completedBoardTour ?? false,
});
completeTour({ tour: "manage" });
router.push("/manage");
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
{
id: "manage-welcome",
title: t("welcome.title"),
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink("/docs/category/management")}
/>
),
},
{
id: "manage-boards-list",
title: t("boardsList.title"),
content: (
<TourStepContent
description={t("boardsList.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
icon={<IconLayoutDashboardFilled size={18} />}
/>
),
},
{
id: "manage-boards-create",
title: t("boardsCreate.title"),
content: (
<TourStepContent
description={t("boardsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-apps-list",
title: t("appsList.title"),
content: (
<TourStepContent
description={t("appsList.description")}
documentationHref={createDocumentationLink("/docs/management/apps")}
icon={<IconAppsFilled size={18} />}
/>
),
},
{
id: "manage-apps-create",
title: t("appsCreate.title"),
content: (
<TourStepContent
description={t("appsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/apps")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-integrations-list",
title: t("integrationsList.title"),
content: (
<TourStepContent
description={t("integrationsList.description")}
documentationHref={createDocumentationLink("/docs/management/integrations")}
icon={<IconAffiliateFilled size={18} />}
/>
),
},
{
id: "manage-integrations-create",
title: t("integrationsCreate.title"),
content: (
<TourStepContent
description={t("integrationsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/integrations")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-users-list",
title: t("usersList.title"),
adminOnly: true,
focusRevealProps: usersStepFocusRevealProps,
content: (
<TourStepContent
description={t("usersList.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
icon={<IconUserFilled size={18} />}
/>
),
},
{
id: "manage-users-create",
title: t("usersCreate.title"),
adminOnly: true,
focusRevealProps: usersStepFocusRevealProps,
content: (
<TourStepContent
description={t("usersCreate.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
icon={<IconPlus size={18} />}
/>
),
},
];
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
}, [isAdmin, t]);
return (
<TourShell
steps={steps}
started={tourActive}
onEnd={handleEnd}
stepRoutes={stepRoutes}
position={{ base: "bottom", sm: "right" }}
>
{children}
</TourShell>
);
};
@@ -0,0 +1,197 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import type { OnboardingTourController, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
import type { FloatingPosition } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
interface TourShellProps extends PropsWithChildren {
steps: OnboardingTourStep[];
started: boolean;
onEnd: () => void;
stepRoutes?: Record<string, string>;
position?: FloatingPosition | Record<string, FloatingPosition>;
}
type TourController = OnboardingTourController;
const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0;
const stepAtOffset = (controller: TourController, offset: number) => {
const index = stepIndexFromController(controller) + offset;
return controller.tour[index];
};
const pollForElement = (targetId: string, callback: () => void) => {
const interval = setInterval(() => {
const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
if (targetElement) {
clearInterval(interval);
callback();
}
}, 50);
const timeout = setTimeout(() => clearInterval(interval), 5000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
};
const isTypingTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
};
interface TourForwardButtonProps {
label: string;
onClick: () => void;
}
const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
return (
<Button size="sm" variant="light" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
{label}
</Button>
);
};
interface TourDoneButtonProps {
label: string;
onClick: () => void;
}
const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
return (
<Button size="sm" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
{label}
</Button>
);
};
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
const t = useI18n();
const router = useRouter();
const forwardActionRef = useRef<(() => void) | null>(null);
const navigateAndAdvance = useCallback(
(currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
if (!stepRoutes || !targetStepId) {
advance();
return;
}
const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
const targetRoute = stepRoutes[targetStepId];
if (targetRoute && targetRoute !== currentRoute) {
router.push(targetRoute);
pollForElement(targetStepId, advance);
} else {
advance();
}
},
[router, stepRoutes],
);
const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
forwardActionRef.current = action;
return action;
}, []);
useEffect(() => {
if (!started) {
forwardActionRef.current = null;
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
event.preventDefault();
forwardActionRef.current?.();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [started]);
return (
<OnboardingTour
tour={steps}
started={started}
onOnboardingTourEnd={onEnd}
withStepper={false}
header={(controller) => (
<>
<Group justify="center" gap={4} py={4}>
<Text size="sm" fw={600}>
{stepIndexFromController(controller) + 1}
</Text>
<Text size="sm" c="dimmed">
/
</Text>
<Text size="sm" c="dimmed">
{controller.tour.length}
</Text>
</Group>
{stepIndexFromController(controller) === 0 && (
<Center py="xs">
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
</Center>
)}
</>
)}
nextStepNavigation={(controller) => {
const nextStep = stepAtOffset(controller, 1);
const action = bindForwardAction(controller, () => {
navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
});
return <TourForwardButton label={t("onboardingTour.next")} onClick={action} />;
}}
endStepNavigation={(controller) => {
const action = bindForwardAction(controller, () => controller.endTour());
return <TourDoneButton label={t("onboardingTour.done")} onClick={action} />;
}}
prevStepNavigation={(controller) => (
<Button
size="sm"
variant="default"
onClick={() => {
const prevStep = stepAtOffset(controller, -1);
navigateAndAdvance(controller.currentStep?.id, prevStep?.id, () => controller.prevStep());
}}
>
{t("onboardingTour.prev")}
</Button>
)}
skipNavigation={(controller) => (
<Button size="sm" variant="subtle" color="gray" onClick={() => controller.skipTour()}>
{t("onboardingTour.skip")}
</Button>
)}
withPrevButton
focusRevealProps={{
disableTargetInteraction: true,
popoverProps: {
position: position ?? { base: "bottom", sm: "right" },
width: 420,
shadow: "xl",
radius: "lg",
middlewares: { shift: { padding: 16 }, flip: true },
},
}}
cutoutPadding={12}
cutoutRadius={12}
>
{children}
</OnboardingTour>
);
};
@@ -0,0 +1,39 @@
"use client";
import type { ReactNode } from "react";
import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface TourStepContentProps {
description: string;
documentationHref: string;
icon?: ReactNode;
}
export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
const t = useI18n();
return (
<Stack gap="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
{icon && (
<ThemeIcon variant="light" size="lg" radius="md" mt={2}>
{icon}
</ThemeIcon>
)}
<Text size="sm" lh={1.7} style={{ flex: 1 }}>
{description}
</Text>
</Group>
<Divider />
<Anchor href={documentationHref} target="_blank" size="xs" fw={500}>
<Group gap={4} wrap="nowrap">
<IconExternalLink size={14} />
{t("onboardingTour.learnMore")}
</Group>
</Anchor>
</Stack>
);
};
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
window.location.assign(logoutUrl); window.location.assign(logoutUrl);
return; return;
} }
router.refresh(); router.push("/auth/login");
}, },
}); });
}, [logoutUrl, openModal, router]); }, [logoutUrl, openModal, router]);
+1
View File
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({ export const env = createEnv({
server: { server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false), UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
}, },
experimental__runtimeEnv: process.env, experimental__runtimeEnv: process.env,
}); });
+20 -10
View File
@@ -19,6 +19,9 @@
<!-- Links --> <!-- Links -->
<p align="center"> <p align="center">
<a href="https://demo.homarr.dev">
<strong>Live Demo 🎮</strong>
</a> •
<a href="https://homarr.dev/docs/category/installation-1/"> <a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong> <strong>Install 💻</strong>
</a> • </a> •
@@ -246,13 +249,20 @@
</a> </a>
</td> </td>
<td align="center"> <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"> <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" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
<br/> <br/>
<p align="center">Pi-hole</p> <p align="center">Pi-hole</p>
</a> </a>
</td></tr> </td>
<tr><td align="center"> <td align="center">
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener"> <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" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
<br/> <br/>
@@ -293,15 +303,15 @@
<br/> <br/>
<p align="center">Radarr</p> <p align="center">Radarr</p>
</a> </a>
</td> </td></tr>
<td align="center"> <tr><td align="center">
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener"> <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" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
<br/> <br/>
<p align="center">Readarr</p> <p align="center">Readarr</p>
</a> </a>
</td></tr> </td>
<tr><td align="center"> <td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener"> <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" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/> <br/>
@@ -342,15 +352,15 @@
<br/> <br/>
<p align="center">Speedtest<br/>Tracker</p> <p align="center">Speedtest<br/>Tracker</p>
</a> </a>
</td> </td></tr>
<td align="center"> <tr><td align="center">
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener"> <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" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
<br/> <br/>
<p align="center">Tdarr</p> <p align="center">Tdarr</p>
</a> </a>
</td></tr> </td>
<tr><td align="center"> <td align="center">
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener"> <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" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
<br/> <br/>
+2
View File
@@ -31,6 +31,7 @@ describe("Onboarding", () => {
confirmPassword: "Comp(exP4sswOrd", confirmPassword: "Comp(exP4sswOrd",
}); });
await actions.processSettingsStepAsync(); await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert // Assert
await assertions.assertFinishStepVisibleAsync(); await assertions.assertFinishStepVisibleAsync();
@@ -72,6 +73,7 @@ describe("Onboarding", () => {
name: externalGroupName, name: externalGroupName,
}); });
await actions.processSettingsStepAsync(); await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert // Assert
await assertions.assertFinishStepVisibleAsync(); await assertions.assertFinishStepVisibleAsync();
+5
View File
@@ -51,4 +51,9 @@ export class OnboardingActions {
await this.page.waitForSelector("text=Analytics"); await this.page.waitForSelector("text=Analytics");
await this.page.locator("css=button[type='submit']").click(); await this.page.locator("css=button[type='submit']").click();
} }
public async processIntegrationsStepAsync() {
await this.page.waitForSelector("text=integrations you'd like to connect");
await this.page.locator("button", { hasText: "Skip for now" }).click();
}
} }
@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm";
import { expect } from "vitest"; import { expect } from "vitest";
import * as sqliteSchema from "../../../packages/db/schema/sqlite"; import * as sqliteSchema from "../../../packages/db/schema/sqlite";
import { OnboardingStep } from "../../../packages/definitions/src"; import type { OnboardingStep } from "../../../packages/definitions/src";
import { credentialsAdminGroup } from "../../../packages/definitions/src/group"; import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
import type { SqliteDatabase } from "../e2e-db"; import type { SqliteDatabase } from "../e2e-db";
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
} }
public async assertFinishStepVisibleAsync() { public async assertFinishStepVisibleAsync() {
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 }); await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
} }
} }
+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", "name": "homarr",
"version": "1.61.1", "version": "1.64.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
@@ -94,7 +94,7 @@
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2", "tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.7", "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": { "patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch", "@types/node-unifi": "patches/@types__node-unifi.patch",
+1
View File
@@ -22,6 +22,7 @@
}, },
"dependencies": { "dependencies": {
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-status": "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 { z } from "zod/v4";
import { createEnv } from "@homarr/core/infrastructure/env"; import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({ export const env = createEnv({
server: { server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
}, },
runtimeEnv: { runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, 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 { env } from "@homarr/docker/env";
import { publicProcedure } from "../trpc"; import { isDemoMode, publicProcedure } from "../trpc";
export const dockerMiddleware = () => { export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => { return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER) { if (env.ENABLE_DOCKER || isDemoMode) {
return await next(); return await next();
} }
throw new TRPCError({ throw new TRPCError({
+1
View File
@@ -28,6 +28,7 @@ export const appRouter = createTRPCRouter({
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)), certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)), bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)), info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
config: lazy(() => import("./router/config").then((mod) => mod.configRouter)),
}); });
// export type definition of API // export type definition of API
+117
View File
@@ -35,6 +35,12 @@ import {
getPermissionsWithParents, getPermissionsWithParents,
widgetKinds, widgetKinds,
} from "@homarr/definitions"; } from "@homarr/definitions";
import {
exportBoardBundleAsync,
importBoardBundleAsync,
importHomepageServicesAsync,
parseBundleJson,
} from "@homarr/board-portability";
import { importOldmarrAsync } from "@homarr/old-import"; import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared"; import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema"; import { oldmarrConfigSchema } from "@homarr/old-schema";
@@ -43,6 +49,9 @@ import {
boardChangeVisibilitySchema, boardChangeVisibilitySchema,
boardCreateSchema, boardCreateSchema,
boardDuplicateSchema, boardDuplicateSchema,
boardExportBundleSchema,
boardImportBundleSchema,
boardNameSchema,
boardRenameSchema, boardRenameSchema,
boardSaveLayoutsSchema, boardSaveLayoutsSchema,
boardSavePartialSettingsSchema, boardSavePartialSettingsSchema,
@@ -54,6 +63,7 @@ import { zodUnionFromArray } from "@homarr/validation/enums";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared"; import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import packageJson from "../../../../package.json";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access"; import { throwIfActionForbiddenAsync } from "./board/board-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm"; import { generateResponsiveGridFor } from "./board/grid-algorithm";
@@ -1327,6 +1337,113 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content)); const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration); 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" });
}
}),
});
+111 -2
View File
@@ -3,8 +3,13 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker"; import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker"; import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker"; import { getServerSettingsAsync } from "@homarr/db/queries";
import {
dockerContainersRequestHandler,
getContainerLogsAsync,
streamContainerLogsAsync,
} from "@homarr/request-handler/docker";
import { dockerMiddleware } from "../../middlewares/docker"; import { dockerMiddleware } from "../../middlewares/docker";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
@@ -104,6 +109,110 @@ export const dockerRouter = createTRPCRouter({
const innerHandler = dockerContainersRequestHandler.handler({}); const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync(); await innerHandler.invalidateAsync();
}), }),
getDiscoveredPreview: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
return await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
}),
syncDiscovery: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.mutation(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
const discovered = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
return await syncDiscoveredServicesAsync(discovered, {
targetBoardName: serverSettings.docker.targetBoardName,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
defaultItemWidth: serverSettings.docker.defaultItemWidth,
defaultItemHeight: serverSettings.docker.defaultItemHeight,
});
}),
logs: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.input(
z.object({
id: z.string(),
tail: z.number().min(1).max(1000).optional(),
}),
)
.query(async ({ input }) => {
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
if (!logs) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Container not found",
});
}
return {
logs,
};
}),
subscribeLogs: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.input(
z.object({
id: z.string(),
tail: z.number().min(1).max(1000).optional(),
}),
)
.subscription(({ input }) => {
return observable<string>((emit) => {
let cleanupFn: (() => void) | undefined;
let isSubscribed = true;
const initializeStreamAsync = async () => {
try {
cleanupFn = await streamContainerLogsAsync(
input.id,
input.tail ?? 200,
(data) => {
if (isSubscribed) {
emit.next(data);
}
},
(err) => {
if (isSubscribed) {
emit.error(
new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: err.message,
}),
);
}
},
);
} catch (err) {
if (isSubscribed) {
emit.error(
new TRPCError({
code: "NOT_FOUND",
message: err instanceof Error ? err.message : "Container not found",
}),
);
}
}
};
void initializeStreamAsync();
return () => {
isSubscribed = false;
cleanupFn?.();
};
});
}),
}); });
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
@@ -1,5 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; 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 { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
import { import {
ensureValidTokenOrThrow, ensureValidTokenOrThrow,
@@ -7,6 +10,7 @@ import {
importInitialOldmarrInputSchema, importInitialOldmarrInputSchema,
} from "@homarr/old-import/import"; } from "@homarr/old-import/import";
import packageJson from "../../../../../package.json";
import { createTRPCRouter, onboardingProcedure } from "../../trpc"; import { createTRPCRouter, onboardingProcedure } from "../../trpc";
import { nextOnboardingStepAsync } from "../onboard/onboard-queries"; import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
@@ -19,12 +23,7 @@ export const importRouter = createTRPCRouter({
}), }),
validateToken: onboardingProcedure validateToken: onboardingProcedure
.requiresStep("import") .requiresStep("import")
.input( .input(z.object({ checksum: z.string(), token: z.string() }))
z.object({
checksum: z.string(),
token: z.string(),
}),
)
.mutation(({ input }) => { .mutation(({ input }) => {
try { try {
ensureValidTokenOrThrow(input.checksum, input.token); ensureValidTokenOrThrow(input.checksum, input.token);
@@ -40,4 +39,28 @@ export const importRouter = createTRPCRouter({
await importInitialOldmarrAsync(ctx.db, input); await importInitialOldmarrAsync(ctx.db, input);
await nextOnboardingStepAsync(ctx.db, undefined); 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 z from "zod/v4";
import packageJson from "../../../../package.json"; import packageJson from "../../../../package.json";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({ export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure getInfo: protectedProcedure
@@ -13,4 +13,5 @@ export const infoRouter = createTRPCRouter({
version: packageJson.version, version: packageJson.version,
}; };
}), }),
isDemoMode: publicProcedure.query(() => isDemoMode),
}); });
@@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,11 +16,9 @@ import {
integrations, integrations,
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { import {
getIconUrl,
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
integrationCategories, integrationCategories,
@@ -33,6 +33,7 @@ import {
integrationSavePermissionsSchema, integrationSavePermissionsSchema,
integrationUpdateSchema, integrationUpdateSchema,
} from "@homarr/validation/integration"; } from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
@@ -41,6 +42,7 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn
import { mapTestConnectionError } from "./map-test-connection-error"; import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" }); const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
@@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({
}, },
}; };
}) })
.sort( .toSorted(
(integrationA, integrationB) => (integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
@@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({
url: input.url, url: input.url,
}); });
if ( // Media request search is a first-class Spotlight action. Do not create a search engine for it.
input.attemptSearchEngineCreation &&
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
) {
const icon = getIconUrl(input.kind);
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
integrationId,
type: "fromIntegration",
iconUrl: icon,
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
});
}
}), }),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({
}); });
return { return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => { inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
users: userPermissions users: userPermissions
@@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({
user, user,
permission, permission,
})) }))
.sort((permissionA, permissionB) => { .toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}), }),
groups: dbGroupIntegrationPermission groups: dbGroupIntegrationPermission
@@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
}, },
permission, permission,
})) }))
.sort((permissionA, permissionB) => { .toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name); return permissionA.group.name.localeCompare(permissionB.group.name);
}), }),
}; };
@@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration); const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query)); return await integrationInstance.searchAsync(encodeURI(input.query));
}), }),
mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => {
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
return integrationsWithAccess.map((integration) => {
const permissions = constructIntegrationPermissions(integration, ctx.session);
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
};
});
}),
searchMediaRequests: protectedProcedure
.input(
z.object({
query: z.string(),
integrationIds: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const query = input.query.trim();
if (query.length === 0) return [];
const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds);
const results = await Promise.all(
integrationsWithAccess.map(async (integration) => {
const integrationInstance = await createIntegrationAsync({
id: integration.id,
name: integration.name,
url: integration.url,
kind: integration.kind as (typeof mediaRequestSearchKinds)[number],
externalUrl: integration.app?.href ?? null,
decryptedSecrets: integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});
const permissions = constructIntegrationPermissions(integration, ctx.session);
return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) =>
searchResults.map((result) => ({
...result,
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasInteractAccess: permissions.hasInteractAccess,
},
},
})),
);
}),
);
return results.flat();
}),
getMediaRequestOptions: protectedProcedure
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
}); });
interface IntegrationRouterContext {
db: Database;
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
}
const getAccessibleMediaRequestSearchIntegrationsAsync = async (
ctx: IntegrationRouterContext,
integrationIds?: string[],
) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
where:
integrationIds && integrationIds.length > 0
? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds))
: inArray(integrations.kind, mediaRequestSearchKinds),
orderBy: asc(integrations.name),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
if (integrationIds && integrationsFromDb.length !== integrationIds.length) {
throw new TRPCError({
code: "NOT_FOUND",
message: "One or more media request search integrations were not found",
});
}
const integrationsWithAccess = await Promise.all(
integrationsFromDb.map(async (integration) => ({
integration,
hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session),
})),
);
if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to query one or more media request search integrations",
});
}
return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration);
};
interface UpdateSecretInput { interface UpdateSecretInput {
integrationId: string; integrationId: string;
value: string; value: string;
@@ -640,35 +770,6 @@ interface AddSecretInput {
kind: IntegrationSecretKind; kind: IntegrationSecretKind;
} }
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
const searchEngines = await db.query.searchEngines.findMany({
columns: {
short: true,
},
});
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
if (!usedShortNames.includes(nameByIntegrationName)) {
return nameByIntegrationName;
}
// 8 is max length constraint
for (let i = 2; i < 9999999; i++) {
const generatedName = `${nameByIntegrationName}${i}`;
if (usedShortNames.includes(generatedName)) {
continue;
}
return generatedName;
}
throw new Error(
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
);
};
const addSecretAsync = async (db: Database, input: AddSecretInput) => { const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({ await db.insert(integrationSecrets).values({
kind: input.kind, kind: input.kind,
@@ -76,6 +76,9 @@ const nextSteps: Partial<Record<OnboardingStep, Partial<Record<OnboardingStep, N
settings: true, settings: true,
}, },
settings: { settings: {
integrations: true,
},
integrations: {
finish: true, finish: true,
}, },
}; };
@@ -1,12 +1,57 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { onboarding } from "@homarr/db/schema"; import { createId } from "@homarr/common";
import { onboardingSteps } from "@homarr/definitions"; import { encryptSecret } from "@homarr/common/server";
import { eq, like, or } from "@homarr/db";
import { placeAllWidgetsAsync } from "@homarr/db/queries";
import {
apps,
groups,
icons,
integrationItems,
integrations,
integrationSecrets,
itemLayouts,
items,
onboarding,
} from "@homarr/db/schema";
import {
everyoneGroup,
extractContainerImageName,
getIconUrl,
integrationKinds,
integrationSecretKinds,
matchIntegrationKindFromContainer,
onboardingSteps,
} from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { zodEnumFromArray } from "@homarr/validation/enums"; import { zodEnumFromArray } from "@homarr/validation/enums";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc";
import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection";
import { mapTestConnectionError } from "../integration/map-test-connection-error";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries"; import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
interface PortInfo {
IP?: string;
PublicPort?: number;
}
interface SuggestedUrlResult {
url: string;
publishedPort: number | null;
}
const buildSuggestedUrl = (ports: PortInfo[] | undefined, host: string): SuggestedUrlResult => {
const port = ports?.[0];
if (port?.PublicPort) {
const ip = port.IP && port.IP !== "0.0.0.0" && port.IP !== "::" ? port.IP : host;
return { url: `http://${ip}:${port.PublicPort}`, publishedPort: port.PublicPort };
}
return { url: "", publishedPort: null };
};
export const onboardRouter = createTRPCRouter({ export const onboardRouter = createTRPCRouter({
currentStep: publicProcedure.query(async ({ ctx }) => { currentStep: publicProcedure.query(async ({ ctx }) => {
return await getOnboardingOrFallbackAsync(ctx.db); return await getOnboardingOrFallbackAsync(ctx.db);
@@ -33,4 +78,249 @@ export const onboardRouter = createTRPCRouter({
step: "start", step: "start",
}); });
}), }),
createIntegration: onboardingProcedure
.requiresStep("integrations")
.input(
z.object({
name: z.string().nonempty().max(127),
url: z.string().nonempty(),
kind: zodEnumFromArray(integrationKinds),
secrets: z.array(
z.object({
kind: zodEnumFromArray(integrationSecretKinds),
value: z.string().nonempty(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
const result = await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
}).catch((error) => {
if (!(error instanceof MissingSecretError)) throw error;
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message,
});
});
if (!result.success) {
return { error: mapTestConnectionError(result.error) };
}
const integrationId = createId();
const appId = createId();
await ctx.db.insert(apps).values({
id: appId,
name: input.name,
iconUrl: getIconUrl(input.kind),
href: input.url,
pingUrl: input.url,
description: null,
});
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
});
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
}),
discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
const emptyResult = {
integrations: [] as {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}[],
apps: [] as {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}[],
};
try {
const { DockerSingleton, dockerLabels } = await import("@homarr/docker");
const dockerInstances = DockerSingleton.getInstances();
if (dockerInstances.length === 0) {
return { status: "empty" as const, ...emptyResult };
}
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const containerList = await instance.listContainers({ all: false });
return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host }));
}),
);
const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
if (containers.length === 0) {
return { status: "empty" as const, ...emptyResult };
}
const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`));
const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : [];
const seenKinds = new Set<IntegrationKind>();
const discoveredIntegrations = emptyResult.integrations;
const discoveredApps = emptyResult.apps;
const cdnIconUrl = (slug: string) =>
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
const strictIconMatch = (imageName: string) => {
const normalized = imageName.toLowerCase().trim();
if (normalized.length < 3) return null;
const exact = dbIcons.find((icon) => icon.name.toLowerCase() === normalized);
if (exact) return exact.url;
return dbIcons.find((icon) => icon.name.toLowerCase().startsWith(normalized))?.url ?? null;
};
for (const container of containers) {
const imageName = extractContainerImageName(container.Image);
const containerName = container.Names[0]?.split("/")[1] ?? "Unknown";
const dbIcon = strictIconMatch(imageName);
const iconUrl = dbIcon ?? cdnIconUrl(imageName.toLowerCase());
const { url: suggestedUrl, publishedPort } = buildSuggestedUrl(container.Ports, container.host);
const kind = matchIntegrationKindFromContainer({
image: container.Image,
name: containerName,
});
if (kind && !seenKinds.has(kind)) {
seenKinds.add(kind);
discoveredIntegrations.push({
containerId: container.Id,
containerName,
kind,
suggestedUrl,
publishedPort,
iconUrl,
});
} else if (!kind && dbIcon) {
discoveredApps.push({
containerId: container.Id,
containerName,
suggestedUrl,
publishedPort,
iconUrl,
});
}
}
return {
status: "success" as const,
integrations: discoveredIntegrations,
apps: discoveredApps,
};
} catch {
return { status: "unavailable" as const, ...emptyResult };
}
}),
createAppsFromDiscovery: onboardingProcedure
.requiresStep("integrations")
.input(
z.array(
z.object({
name: z.string().nonempty().max(127),
href: z.string().nullable(),
iconUrl: z.string().nullable(),
}),
),
)
.mutation(async ({ ctx, input }) => {
if (input.length === 0) return;
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
await ctx.db.insert(apps).values(
input.map((app) => ({
id: createId(),
name: app.name,
iconUrl: app.iconUrl ?? defaultIcon,
href: app.href,
description: null,
})),
);
}),
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
const db = ctx.db;
const allIntegrations = await db.query.integrations.findMany();
const allApps = await db.query.apps.findMany();
const board = await db.query.boards.findFirst({
with: {
sections: true,
layouts: true,
},
});
if (!board) {
await nextOnboardingStepAsync(db, undefined);
return;
}
const everyoneGroupRow = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
});
if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) {
await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id));
}
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
const layout = board.layouts[0];
if (section && layout) {
const existingItems = await db.query.items.findMany({
where: eq(items.boardId, board.id),
});
for (const item of existingItems) {
await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id));
await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id));
}
if (existingItems.length > 0) {
await db.delete(items).where(eq(items.boardId, board.id));
}
await placeAllWidgetsAsync(
db,
{
boardId: board.id,
sectionId: section.id,
layoutId: layout.id,
columnCount: layout.columnCount,
},
allIntegrations,
allApps,
);
}
await nextOnboardingStepAsync(db, undefined);
}),
}); });
@@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db"; import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema"; import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "searchEngineRouter" }); const logger = createLogger({ module: "searchEngineRouter" });
@@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit, limit: input.limit,
}); });
}), }),
getMediaRequestOptions: protectedProcedure
.concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
.input(mediaRequestOptionsSchema)
.query(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
create: permissionRequiredProcedure create: permissionRequiredProcedure
.requiresPermission("search-engine-create") .requiresPermission("search-engine-create")
.input(searchEngineManageSchema) .input(searchEngineManageSchema)
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff. Show more