Compare commits

...
Author SHA1 Message Date
ajnart 53fcc6997c style: auto-fix lint and format 2026-06-07 22:04:04 +00:00
Thomas Camlong 69e64356bb feat(widgets): add size-aware widget hook
Introduce useWidgetSize() hook and WidgetSizeProvider context that
exposes grid dimensions (gridWidth, gridHeight) to every widget,
enabling responsive rendering based on how many grid cells a widget
occupies.

Weather widget updated as proof of concept: when placed in a 1x1 or
1xN cell with forecast enabled, it switches to a compact layout that
hides the forecast row, wind speed, and city name to prevent clipping.
2026-06-08 00:02:57 +02:00
homarr-renovate[bot] f2705eeb57 chore(deps): update dependency oxfmt to ^0.53.0 (#5883)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 23:12:23 +00:00
homarr-renovate[bot] ac37d2c73f chore(deps): update react monorepo (#5886)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 21:13:36 +00:00
homarr-renovate[bot] e00ea624cd chore(deps): update nextjs monorepo to v16.2.7 (#5885)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 19:32:30 +00:00
homarr-renovate[bot] 20b62c42e3 chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.0 (#5878)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 13:54:21 +00:00
homarr-renovate[bot] 59971991c7 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.16 (#5880)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 11:36:28 +00:00
homarr-renovate[bot] a629681ae5 chore(deps): update vitest monorepo to ^4.1.8 (#5879)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 09:34:48 +00: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
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
230 changed files with 15427 additions and 1825 deletions

No files matched your search

+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
+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
+1
View File
@@ -18,6 +18,7 @@
"@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",
@@ -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";
@@ -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>
); );
}; };
@@ -1,13 +1,14 @@
"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";
@@ -20,7 +21,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 +41,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,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, 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>
@@ -27,6 +28,6 @@ export const InitStart = async () => {
{tStart("action.importOldmarr")} {tStart("action.importOldmarr")}
</InitStartButton> </InitStartButton>
</Stack> </Stack>
</Card> </InitStepCard>
); );
}; };
@@ -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>
); );
@@ -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>
+16 -1
View File
@@ -36,6 +36,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 +46,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 +62,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 +79,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"),
@@ -153,6 +161,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 +200,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>;
} }
@@ -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,81 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
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 canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 14,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
isTerminalReadyRef.current = true;
const terminal = terminalRef.current;
pendingLogsRef.current.forEach((data) => terminal.write(data));
pendingLogsRef.current = [];
terminal.refresh(0, terminal.rows - 1);
setTimeout(() => {
const fitAddon = new FitAddon();
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
isTerminalReadyRef.current = false;
pendingLogsRef.current = [];
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [writeToTerminal]);
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
};
@@ -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 />
@@ -13,9 +13,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); const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
clientApi.log.subscribe.useSubscription( clientApi.log.subscribe.useSubscription(
{ {
levels: activeLevels, levels: activeLevels,
@@ -26,7 +28,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);
}, },
}, },
@@ -43,22 +44,30 @@ export const TerminalComponent = () => {
cursorBlink: false, cursorBlink: false,
disableStdin: true, disableStdin: true,
convertEol: true, convertEol: true,
fontSize,
}); });
terminalRef.current.open(ref.current); terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon); terminalRef.current.loadAddon(canvasAddon);
// This is a hack to make sure the terminal is rendered before we try to fit it
// You can blame @Meierschlumpf for this
setTimeout(() => { setTimeout(() => {
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon); terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit(); fitAddon.fit();
}); });
return () => { return () => {
terminalRef.current?.dispose();
canvasAddon.dispose(); canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.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,6 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack"; import type { GridItemHTMLElement } from "@homarr/gridstack";
import { WidgetSizeProvider } from "@homarr/widgets";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content"; import { BoardItemContent } from "../items/item-content";
@@ -18,7 +19,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770 * @see https://github.com/homarr-labs/homarr/pull/1770
*/ */
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
return [...items, ...innerSections].sort((itemA, itemB) => { return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) { if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset; return itemA.xOffset - itemB.xOffset;
} }
@@ -59,7 +60,13 @@ const Item = ({ item, innerRef }: ItemProps) => {
minWidth={minWidth} minWidth={minWidth}
minHeight={minHeight} minHeight={minHeight}
> >
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />} {item.type === "item" ? (
<WidgetSizeProvider gridWidth={item.width} gridHeight={item.height}>
<BoardItemContent item={item} />
</WidgetSizeProvider>
) : (
<BoardDynamicSection section={item} />
)}
</GridStackItem> </GridStackItem>
); );
}; };
@@ -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,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 });
} }
} }
+4 -4
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",
@@ -88,13 +88,13 @@
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11", "nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0", "prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6", "proxmox-api>undici": "7.24.6",
"react-is": "^19.2.6", "react-is": "^19.2.7",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4", "rollup@>=4.0.0 <4.22.4": ">=4.61.0",
"sha.js@<=2.4.11": ">=2.4.12", "sha.js@<=2.4.11": ">=2.4.12",
"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.16"
}, },
"patchedDependencies": { "patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch", "@types/node-unifi": "patches/@types__node-unifi.patch",
+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({
@@ -4,7 +4,11 @@ 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 } from "@homarr/docker";
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker"; 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 +108,84 @@ export const dockerRouter = createTRPCRouter({
const innerHandler = dockerContainersRequestHandler.handler({}); const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync(); await innerHandler.invalidateAsync();
}), }),
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) => {
+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)
@@ -13,6 +13,14 @@ import { dockerRouter } from "../../docker/docker-router";
// Mock the auth module to return an empty session // Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/request-handler/docker", () => ({ vi.mock("@homarr/request-handler/docker", () => ({
getContainerLogsAsync: async () => {
await Promise.resolve();
return "logs";
},
streamContainerLogsAsync: async () => {
await Promise.resolve();
return () => undefined;
},
dockerContainersRequestHandler: { dockerContainersRequestHandler: {
handler: () => ({ handler: () => ({
getCachedOrUpdatedDataAsync: async () => { getCachedOrUpdatedDataAsync: async () => {
@@ -65,6 +73,8 @@ const validInputs: {
restartAll: { ids: ["1"] }, restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] }, removeAll: { ids: ["1"] },
invalidate: undefined, invalidate: undefined,
logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 },
}; };
describe("All procedures should only be accessible for users with admin permission", () => { describe("All procedures should only be accessible for users with admin permission", () => {
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server"; import { encryptSecret } from "@homarr/common/server";
import { apps, integrations, integrationSecrets } from "@homarr/db/schema"; import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test"; import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions"; import type { GroupPermissionKey } from "@homarr/definitions";
@@ -59,6 +59,127 @@ describe("all should return all integrations", () => {
}); });
}); });
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
test("with integration use access should only return media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-use-all"]),
});
await db.insert(integrations).values([
{
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
},
{
id: "2",
name: "Jellyfin",
kind: "jellyfin",
url: "http://jellyfin.local",
},
]);
const result = await caller.mediaRequestSearchTargets();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
expect(result[0]!.kind).toBe("jellyseerr");
});
test("without access should not return media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
});
const result = await caller.mediaRequestSearchTargets();
expect(result).toHaveLength(0);
});
test("with direct integration use access should return media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
});
await db.insert(users).values({ id: defaultUserId });
await db.insert(integrationUserPermissions).values({
integrationId: "1",
userId: defaultUserId,
permission: "use",
});
const result = await caller.mediaRequestSearchTargets();
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("1");
});
});
describe("searchMediaRequests should validate scoped integrations", () => {
test("with a non media search integration should throw not found", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-use-all"]),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyfin",
kind: "jellyfin",
url: "http://jellyfin.local",
});
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
"One or more media request search integrations were not found",
);
});
test("without query access to a scoped media search integration should throw forbidden", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
session: defaultSessionWithPermissions(),
});
await db.insert(integrations).values({
id: "1",
name: "Jellyseerr",
kind: "jellyseerr",
url: "http://jellyseerr.local",
});
await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
"User does not have permission to query one or more media request search integrations",
);
});
});
describe("byId should return an integration by id", () => { describe("byId should return an integration by id", () => {
test("with full access should return an integration by id", async () => { test("with full access should return an integration by id", async () => {
const db = createDb(); const db = createDb();
@@ -208,7 +329,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow); expect(dbSecret!.updatedAt).toEqual(fakeNow);
}); });
test("with create integration access should create a new integration when creating search engine", async () => { test("with create integration access should not create a search engine for media request search integrations", async () => {
const db = createDb(); const db = createDb();
const caller = integrationRouter.createCaller({ const caller = integrationRouter.createCaller({
db, db,
@@ -243,12 +364,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow); expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); expect(dbSearchEngine).toBeUndefined();
expect(dbSearchEngine!.short).toBe("j");
expect(dbSearchEngine!.name).toBe(input.name);
expect(dbSearchEngine!.iconUrl).toBe(
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
);
}); });
test("with create integration access should create a new integration with new linked app", async () => { test("with create integration access should create a new integration with new linked app", async () => {
+47 -2
View File
@@ -32,6 +32,7 @@ import {
import { convertIntersectionToZodObject } from "../schema-merger"; import { convertIntersectionToZodObject } from "../schema-merger";
import { import {
createTRPCRouter, createTRPCRouter,
isDemoMode,
onboardingProcedure, onboardingProcedure,
permissionRequiredProcedure, permissionRequiredProcedure,
protectedProcedure, protectedProcedure,
@@ -278,6 +279,8 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: true, openSearchInNewTab: true,
ddgBangs: true, ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
}), }),
) )
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } }) .meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
@@ -304,6 +307,8 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: true, openSearchInNewTab: true,
ddgBangs: true, ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
}, },
where: eq(users.id, input.userId), where: eq(users.id, input.userId),
}); });
@@ -542,7 +547,7 @@ export const userRouter = createTRPCRouter({
.set({ .set({
pingIconsEnabled: input.pingIconsEnabled, pingIconsEnabled: input.pingIconsEnabled,
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, input.id));
}), }),
changeDdgBangs: protectedProcedure changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
@@ -603,8 +608,48 @@ export const userRouter = createTRPCRouter({
.set({ .set({
firstDayOfWeek: input.firstDayOfWeek, firstDayOfWeek: input.firstDayOfWeek,
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, input.id));
}), }),
completeTour: protectedProcedure
.input(z.object({ tour: z.enum(["manage", "board"]) }))
.mutation(async ({ input, ctx }) => {
const columnByTour = {
manage: { completedManageTour: true },
board: { completedBoardTour: true },
} as const;
await ctx.db.update(users).set(columnByTour[input.tour]).where(eq(users.id, ctx.session.user.id));
}),
resetTours: protectedProcedure.mutation(async ({ ctx }) => {
await ctx.db
.update(users)
.set({
completedManageTour: false,
completedBoardTour: false,
})
.where(eq(users.id, ctx.session.user.id));
}),
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
if (isDemoMode) {
return {
completedManageTour: true,
completedBoardTour: true,
};
}
const user = await ctx.db.query.users.findFirst({
columns: {
completedManageTour: true,
completedBoardTour: true,
},
where: eq(users.id, ctx.session.user.id),
});
return {
completedManageTour: user?.completedManageTour ?? false,
completedBoardTour: user?.completedBoardTour ?? false,
};
}),
}); });
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => { const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
+1
View File
@@ -31,4 +31,5 @@ export const widgetRouter = createTRPCRouter({
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)), tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)), speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
}); });
+51
View File
@@ -0,0 +1,51 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { UpsSummary } from "@homarr/integrations/types";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
return innerHandler.subscribe((summaries) => {
emit.next({
integrationId: integration.id,
summaries,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
+17 -7
View File
@@ -18,6 +18,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions"; import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries"; import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" }); const logger = createLogger({ module: "trpc" });
@@ -87,6 +88,20 @@ export const createCallerFactory = t.createCallerFactory;
*/ */
export const createTRPCRouter = t.router; export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE;
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Mutations are disabled in demo mode",
});
}
return next({ ctx });
});
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/** /**
* Public (unauthed) procedure * Public (unauthed) procedure
* *
@@ -94,19 +109,14 @@ export const createTRPCRouter = t.router;
* tRPC API. It does not guarantee that a user querying is authorized, but you * tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in * can still access user session data if they are logged in
*/ */
export const publicProcedure = t.procedure; export const publicProcedure = baseProcedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
} }
return next({ return next({
ctx: { ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user }, session: { ...ctx.session, user: ctx.session.user },
}, },
}); });
@@ -121,7 +131,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
* *
* @see https://trpc.io/docs/procedures * @see https://trpc.io/docs/procedures
*/ */
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
/** /**
* Procedure that requires a specific permission * Procedure that requires a specific permission
+5 -1
View File
@@ -1,7 +1,11 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
export const removeTrailingSlash = (path: string) => { export const removeTrailingSlash = (path: string) => {
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path; let end = path.length;
while (end > 0 && path.charAt(end - 1) === "/") {
end--;
}
return path.slice(0, end);
}; };
export const extractBaseUrlFromHeaders = ( export const extractBaseUrlFromHeaders = (
@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `completed_manage_tour` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `user` ADD `completed_board_tour` boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -274,6 +274,13 @@
"when": 1775400000000, "when": 1775400000000,
"tag": "0038_add_user_ddg_bangs", "tag": "0038_add_user_ddg_bangs",
"breakpoints": true "breakpoints": true
},
{
"idx": 39,
"version": "5",
"when": 1779199104477,
"tag": "0039_add_user_onboarding_tour_status",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,2 @@
ALTER TABLE "user" ADD COLUMN "completed_manage_tour" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "completed_board_tour" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -36,6 +36,13 @@
"when": 1775400000000, "when": 1775400000000,
"tag": "0004_add_user_ddg_bangs", "tag": "0004_add_user_ddg_bangs",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779199097716,
"tag": "0005_add_user_onboarding_tour_status",
"breakpoints": true
} }
] ]
} }
+304 -6
View File
@@ -1,29 +1,38 @@
import SuperJSON from "superjson";
import { createId, objectKeys } from "@homarr/common"; import { createId, objectKeys } from "@homarr/common";
import { import {
createDocumentationLink, createDocumentationLink,
credentialsAdminGroup, credentialsAdminGroup,
defaultBookmarkApps,
everyoneGroup, everyoneGroup,
getIntegrationDefaultUrl, getIntegrationDefaultUrl,
getIntegrationName, getIntegrationName,
integrationDefs, integrationDefs,
integrationKinds, integrationKinds,
} from "@homarr/definitions"; } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from ".."; import type { Database } from "..";
import { eq } from ".."; import { eq } from "..";
import { getMaxGroupPositionAsync } from "../queries"; import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import { import {
getServerSettingByKeyAsync, getServerSettingByKeyAsync,
insertServerSettingByKeyAsync, insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync, updateServerSettingByKeyAsync,
} from "../queries/server-setting"; } from "../queries/server-setting";
import { import {
apps,
boards, boards,
groupMembers, groupMembers,
groupPermissions, groupPermissions,
groups, groups,
integrationItems,
integrations, integrations,
itemLayouts,
items,
layouts, layouts,
onboarding, onboarding,
searchEngines, searchEngines,
@@ -32,14 +41,26 @@ import {
} from "../schema"; } from "../schema";
import type { Integration } from "../schema"; import type { Integration } from "../schema";
const isTruthyEnv = (value: string | undefined) =>
["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
export const seedDataAsync = async (db: Database) => { export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
console.warn(
"UNSAFE_ENABLE_MOCK_INTEGRATION is enabled: mock integration is available in the UI. Disable by setting UNSAFE_ENABLE_MOCK_INTEGRATION=false.",
);
}
await seedEveryoneGroupAsync(db); await seedEveryoneGroupAsync(db);
await seedOnboardingAsync(db); await seedOnboardingAsync(db);
await seedServerSettingsAsync(db); await seedServerSettingsAsync(db);
await seedDefaultSearchEnginesAsync(db); await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db); await seedDefaultIntegrationsAsync(db);
await seedDefaultAppsAsync(db);
await seedDefaultBoardAsync(db);
await seedBoardWidgetsAsync(db);
if (process.env.DEMO_MODE === "true") { if (isTruthyEnv(process.env.DEMO_MODE)) {
await seedDemoUserAsync(db); await seedDemoUserAsync(db);
} }
}; };
@@ -209,6 +230,192 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
console.log(`Created ${createdCount} default integrations through seeding process`); console.log(`Created ${createdCount} default integrations through seeding process`);
}; };
const seedDefaultAppsAsync = async (db: Database) => {
const existingApps = await db.$count(apps);
if (existingApps > 0) {
console.log("Skipping seeding of default apps as some already exist");
return;
}
for (const app of defaultBookmarkApps) {
await db.insert(apps).values({
id: createId(),
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
});
}
console.log(`Created ${defaultBookmarkApps.length} default apps through seeding process`);
};
const seedDefaultBoardAsync = async (db: Database) => {
const existingBoard = await db.query.boards.findFirst();
if (existingBoard) {
console.log("Skipping seeding of default board as one already exists");
return;
}
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "dashboard",
isPublic: false,
});
await db.insert(sections).values({
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
await db.insert(layouts).values({
id: createId(),
name: "Base",
columnCount: 10,
breakpoint: 0,
boardId,
});
const everyoneGroupRow = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
});
if (everyoneGroupRow) {
await db.update(groups).set({ homeBoardId: boardId }).where(eq(groups.id, everyoneGroupRow.id));
console.log("Set default board as home board for everyone group");
}
console.log("Created default board 'dashboard' through seed");
};
interface DemoWidget {
kind: WidgetKind;
width: number;
height: number;
needsIntegration: boolean;
options?: Record<string, unknown>;
}
const demoApps = [
{
name: "Sonarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
href: "https://sonarr.tv",
},
{
name: "Radarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
href: "https://radarr.video",
},
{
name: "Plex",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
href: "https://plex.tv",
},
{
name: "Jellyfin",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
href: "https://jellyfin.org",
},
{
name: "Proxmox",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg",
href: "https://proxmox.com",
},
{
name: "Pi-hole",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
href: "https://pi-hole.net",
},
{
name: "Grafana",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg",
href: "https://grafana.com",
},
{
name: "Portainer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg",
href: "https://portainer.io",
},
{
name: "Home Assistant",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
href: "https://home-assistant.io",
},
{
name: "Nextcloud",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg",
href: "https://nextcloud.com",
},
{
name: "qBittorrent",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
href: "https://qbittorrent.org",
},
{
name: "Overseerr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
href: "https://overseerr.dev",
},
] as const;
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
// Row 1: calendar + downloads + clock = 12
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
// Row 2: healthMonitoring + bookmarks = 12
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
{
kind: "bookmarks",
width: 6,
height: 3,
needsIntegration: false,
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
},
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
// Row 5: requestList + requestStats + indexerManager = 12
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
{
kind: "rssFeed",
width: 6,
height: 2,
needsIntegration: false,
options: {
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
maximumAmountPosts: 20,
textLinesClamp: 2,
hideDescription: false,
},
},
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
...appIds.map(
(appId): DemoWidget => ({
kind: "app",
width: 2,
height: 1,
needsIntegration: false,
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// Row 9: notebook + dockerContainers + weather = 12
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
];
const seedDemoUserAsync = async (db: Database) => { const seedDemoUserAsync = async (db: Database) => {
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.name, "demo"), where: eq(users.name, "demo"),
@@ -225,7 +432,7 @@ const seedDemoUserAsync = async (db: Database) => {
id: userId, id: userId,
name: "demo", name: "demo",
email: "demo@example.com", email: "demo@example.com",
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2", password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
}); });
const maxPosition = await getMaxGroupPositionAsync(db); const maxPosition = await getMaxGroupPositionAsync(db);
@@ -250,6 +457,27 @@ const seedDemoUserAsync = async (db: Database) => {
previousStep: "settings", previousStep: "settings",
}); });
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Demo Integration",
url: "https://demo.homarr.dev",
kind: "mock",
appId: null,
});
const appIds: string[] = [];
for (const app of demoApps) {
const appId = createId();
appIds.push(appId);
await db.insert(apps).values({
id: appId,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
});
}
const boardId = createId(); const boardId = createId();
await db.insert(boards).values({ await db.insert(boards).values({
id: boardId, id: boardId,
@@ -257,23 +485,93 @@ const seedDemoUserAsync = async (db: Database) => {
isPublic: false, isPublic: false,
creatorId: userId, creatorId: userId,
}); });
const sectionId = createId();
await db.insert(sections).values({ await db.insert(sections).values({
id: createId(), id: sectionId,
kind: "empty", kind: "empty",
xOffset: 0, xOffset: 0,
yOffset: 0, yOffset: 0,
boardId, boardId,
}); });
const layoutId = createId();
await db.insert(layouts).values({ await db.insert(layouts).values({
id: createId(), id: layoutId,
name: "Base", name: "Base",
columnCount: 12, columnCount: 12,
breakpoint: 0, breakpoint: 0,
boardId, boardId,
}); });
const demoWidgets = buildDemoWidgets(appIds);
let xOffset = 0;
let yOffset = 0;
let rowMaxHeight = 0;
for (const widget of demoWidgets) {
if (xOffset + widget.width > 12) {
xOffset = 0;
yOffset += rowMaxHeight;
rowMaxHeight = 0;
}
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
const itemId = createId();
await db.insert(items).values({
id: itemId,
boardId,
kind: widget.kind,
...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}),
});
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId,
xOffset,
yOffset,
width: widget.width,
height: widget.height,
});
if (widget.needsIntegration) {
await db.insert(integrationItems).values({
itemId,
integrationId,
});
}
xOffset += widget.width;
}
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId)); await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
console.log( console.log(
"Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.", "Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.",
); );
}; };
const seedBoardWidgetsAsync = async (db: Database) => {
const existingItems = await db.$count(items);
if (existingItems > 0) {
console.log("Skipping seeding of board widgets as some already exist");
return;
}
const board = await db.query.boards.findFirst({
with: { sections: true, layouts: true },
});
if (!board) return;
const section = board.sections.find((sec) => sec.kind === "empty");
const layout = board.layouts[0];
if (!section || !layout) return;
const allIntegrations = await db.query.integrations.findMany();
const allApps = await db.query.apps.findMany();
const count = await placeAllWidgetsAsync(
db,
{ boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount },
allIntegrations,
allApps,
);
console.log(`Placed ${count} widgets on board`);
};
@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `completed_manage_tour` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `user` ADD `completed_board_tour` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -260,6 +260,13 @@
"when": 1775400000000, "when": 1775400000000,
"tag": "0036_add_user_ddg_bangs", "tag": "0036_add_user_ddg_bangs",
"breakpoints": true "breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1779199046805,
"tag": "0037_add_user_onboarding_tour_status",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -1,3 +1,4 @@
export * from "./item"; export * from "./item";
export * from "./server-setting"; export * from "./server-setting";
export * from "./group"; export * from "./group";
export * from "./widget-placement";
+148
View File
@@ -0,0 +1,148 @@
import superjson from "superjson";
import { createId } from "@homarr/common";
import {
defaultBookmarkApps,
defaultWidgetConfigs,
emptySuperJSON,
getWidgetKindsForIntegration,
} from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import type { Database } from "..";
import { integrationItems, itemLayouts, items } from "../schema";
import type { Integration } from "../schema";
const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
interface BoardTarget {
boardId: string;
sectionId: string;
layoutId: string;
columnCount: number;
}
interface PlacementContext extends BoardTarget {
xOffset: number;
yOffset: number;
rowMaxHeight: number;
}
const placeWidgetAsync = async (
db: Database,
ctx: PlacementContext,
kind: WidgetKind,
linkedIntegrationIds: string[],
options?: string,
size?: { width: number; height: number },
) => {
const itemWidth = size?.width ?? 2;
const itemHeight = size?.height ?? 2;
if (ctx.xOffset + itemWidth > ctx.columnCount) {
ctx.xOffset = 0;
ctx.yOffset += ctx.rowMaxHeight;
ctx.rowMaxHeight = 0;
}
const itemId = createId();
await db.insert(items).values({
id: itemId,
boardId: ctx.boardId,
kind,
options: options ?? emptySuperJSON,
advancedOptions: emptySuperJSON,
});
await db.insert(itemLayouts).values({
itemId,
sectionId: ctx.sectionId,
layoutId: ctx.layoutId,
xOffset: ctx.xOffset,
yOffset: ctx.yOffset,
width: itemWidth,
height: itemHeight,
});
for (const integrationId of linkedIntegrationIds) {
await db.insert(integrationItems).values({ itemId, integrationId });
}
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
ctx.xOffset += itemWidth;
};
interface App {
id: string;
name: string;
}
export const placeAllWidgetsAsync = async (
db: Database,
target: BoardTarget,
allIntegrations: Integration[],
allApps: App[],
) => {
const ctx: PlacementContext = {
...target,
xOffset: 0,
yOffset: 0,
rowMaxHeight: 0,
};
const placedWidgets = new Set<WidgetKind>();
for (const integration of allIntegrations) {
for (const widgetKind of getWidgetKindsForIntegration(integration.kind)) {
if (placedWidgets.has(widgetKind)) continue;
const config = widgetConfigMap.get(widgetKind);
if (config?.skip) continue;
placedWidgets.add(widgetKind);
const matchingIds = allIntegrations
.filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind))
.map((row) => row.id);
const options = config?.options ? superjson.stringify(config.options) : undefined;
await placeWidgetAsync(
db,
ctx,
widgetKind,
matchingIds,
options,
config && { width: config.width, height: config.height },
);
}
}
for (const app of allApps) {
await placeWidgetAsync(
db,
ctx,
"app",
[],
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
{ width: 1, height: 1 },
);
}
const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name));
const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id);
for (const config of defaultWidgetConfigs) {
if (config.skip || placedWidgets.has(config.kind)) continue;
placedWidgets.add(config.kind);
let options = config.options ? { ...config.options } : undefined;
if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) {
options = { ...options, items: bookmarkAppIds };
}
await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, {
width: config.width,
height: config.height,
});
}
return placedWidgets.size + allApps.length;
};
+2
View File
@@ -80,6 +80,8 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(), colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(), pingIconsEnabled: boolean().default(false).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
}); });
export const accounts = mysqlTable( export const accounts = mysqlTable(
+2
View File
@@ -79,6 +79,8 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(), colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(), pingIconsEnabled: boolean().default(false).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
}); });
export const accounts = pgTable( export const accounts = pgTable(
+2
View File
@@ -62,6 +62,8 @@ export const users = sqliteTable("user", {
colorScheme: text().$type<ColorScheme>().default("dark").notNull(), colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(), pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
}); });
export const accounts = sqliteTable( export const accounts = sqliteTable(
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more