Compare commits

...
Author SHA1 Message Date
Thomas 99e013c1fb Merge branch 'dev' into feat/widget-overlay 2026-06-10 16:54:25 +02:00
homarr-renovate[bot] b3cdab579b chore(deps): update dependency isomorphic-dompurify to ^3.16.0 (#5913)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-10 14:31:02 +00:00
homarr-renovate[bot] 44a0c6ce54 chore(deps): update dependency semver-parser to ^4.2.1 (#5868)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 12:47:00 +00:00
homarr-renovate[bot] 7f05e0325d chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.1 (#5906)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 09:53:16 +00:00
homarr-renovate[bot] f71b145403 chore(deps): update tiptap monorepo to v3.25.0 (#5898)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 05:17:18 +00:00
homarr-renovate[bot] 607fd498f4 chore(deps): update tanstack-query monorepo to ^5.101.0 (#5894)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 00:17:40 +00:00
homarr-renovate[bot] 3f2fb54cdf chore(deps): update mantine monorepo to ^9.3.0 (#5890)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 22:39:48 +00:00
Thomasandhomarr-releases[bot] 69412f2d10 chore(release): 1.64.0 [skip ci] (#5902)
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-08 22:25:40 +02:00
Thomas Camlong 786ad347a0 style(board): round overlay on all sides and hide on mobile
Apply full border-radius to hover overlay panel instead of bottom-only,
and add media query to hide the overlay on screens <= 768px since hover
is not available on touch devices.
2026-06-08 22:23:41 +02:00
Afonso Jorge Ramos f6d5f97933 fix(integrations): support truenas json-rpc api, certificate trust, and api key auth (#5861)
* chore(integrations): add ws dependency for the truenas client

* feat(definitions): allow api key authentication for truenas

* fix(integrations): connect to truenas over trusted-certificate websocket with protocol autodetection

* test(integrations): cover truenas protocol detection and authentication

* refactor(integrations): avoid shorthand index variable in truenas mapping

* feat(integrations): add a websocket url helper to the integration base

* refactor(integrations): extract truenas connection handling into a client
2026-06-08 22:20:42 +02:00
homarr-renovate[bot] a447a9f615 chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.17.0 (#5897)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 14:15:50 +00:00
homarr-renovate[bot] 87d3f6a0a1 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.4 (#5896)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 09:30:40 +00:00
Thomas Camlong 93ce4e4a1b perf(board): lift integration query to SectionContent
Avoid N identical useQuery hooks by fetching integrations once
at the parent and passing them as a prop to each WidgetHoverOverlay.
2026-06-07 10:40:35 +02:00
ajnart 4cf41f2c4c style: auto-fix lint and format 2026-06-07 00:06:03 +00:00
Thomas Camlong ab3c8b4d1f feat(board): mount widget hover overlay on dashboard grid items 2026-06-07 02:05:01 +02:00
Thomas Camlong 78ddc81b15 feat(board): add widget hover overlay component 2026-06-07 02:04:57 +02:00
Thomas Camlong ad98f2d262 feat(widgets): export widget option types for hover metadata 2026-06-07 02:04:52 +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
Releases Homarr 8a1ead3674 chore(release): 1.64.0 [skip ci] 2026-06-05 17:35:36 +00:00
homarr-releases[bot] 9c05f44916 chore(release): automatic release v1.64.0 2026-06-05 17:34:47 +00:00
Thomas Camlong efd37dca93 fix(db): log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding
Warn on startup seed when mock integration env flag is set, matching demo mode visibility.
2026-06-05 18:43:33 +02:00
Thomas Camlong 796c679140 chore: merge main into dev 2026-06-05 18:42:31 +02:00
homarr-update-contributors[bot] 76fd4c5fb1 chore: update contributors (#5871) 2026-06-05 18:08:47 +02:00
Thomas Camlong 68f24e34d5 fix: disable onboarding tours when demo mode is enabled
Return completed tour status in demo mode so board and manage tours do not start for demo users.
2026-06-05 18:02:08 +02:00
homarr-update-contributors[bot] 22d7ebab50 chore: update integrations list readme (#5872)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-06-05 15:45:46 +00:00
Thomas Camlong 4f5f3e14a1 fix: sync docs sitemap and remove stale coolify ts-expect-error 2026-06-05 17:39:03 +02:00
Thomasandcopilot-swe-agent[bot] dbf021e04a feat(docker): add container logs modal and stats columns (#4807)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-05 16:57:26 +02:00
CopilotandThomas cc24f070db fix: redirect to login after credentials logout instead of crashing on current page (#5819)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-06-05 16:56:24 +02:00
Thomas 0b663a7fc6 feat(onboarding): guided management and board tours (#5756) 2026-06-05 16:54:50 +02:00
Afonso Jorge Ramos 0dc2558faf feat: add PeaNUT integration and UPS monitoring widget (#5862) 2026-06-05 16:51:09 +02:00
Thomas dd7a4908a3 feat(onboarding): enhance integration setup with Docker discovery and URL templating (#5607) 2026-06-05 16:50:35 +02:00
Thomas 0a6467979a feat: add comprehensive demo mode with mock integrations (#5870) 2026-06-05 16:36:45 +02:00
ThomasandMeier Lukas 25d964a197 refactor(spotlight): registry-driven CMDK preferences (#5817)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-06-05 16:26:48 +02:00
Thomas 3d3e3059a9 feat: media request search as first-class spotlight mode (#5823) 2026-06-05 16:09:14 +02:00
homarr-crowdin[bot]andMeier Lukas 9ae8cb2768 chore(lang): updated translations from crowdin (#5863)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-06-05 13:48:42 +02:00
Thomas 99346f288b feat(widgets): add in-place app editing in widget edit modal (#5809) 2026-06-05 12:31:50 +02:00
Thomas 41fe5928f1 fix: add bookmark widget option to toggle card borders (#5854) 2026-06-04 11:07:31 +02:00
homarr-renovate[bot] 6eb59d6f53 chore(deps): update tiptap monorepo to v3.24.0 (#5858)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-04 08:58:12 +00:00
Afonso Jorge Ramos 2c6b7a818e fix(immich): respect app color scheme in server-stats widget (#5859) 2026-06-04 09:50:02 +02:00
Thomas fd10788838 fix(auth): relax password complexity requirements to suggestions-only (#5764) 2026-06-04 07:56:34 +02:00
Thomas fe4598f722 fix(docker): restore container selection toolbar broken by Mantine v9 (#5855) 2026-06-03 10:03:37 +02:00
Thomas a755692d81 fix(ui): restore pre-v9 light variant colors (#5856) 2026-06-03 06:49:38 +00:00
homarr-renovate[bot] 3ff0d72ba1 chore(deps): update dependency next-intl to v4.13.0 (#5853)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-03 04:21:57 +00:00
homarr-renovate[bot] a63a191203 chore(deps): update turbo monorepo to ^2.9.16 (#5851)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 23:54:09 +00:00
homarr-renovate[bot] 1be906a0cd chore(deps): update dependency isomorphic-dompurify to ^3.15.0 (#5852)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 21:55:53 +00:00
homarr-renovate[bot] 63d8fa8c26 chore(deps): update dependency @ctrl/qbittorrent to ^9.12.2 (#5834)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 18:47:29 +00:00
homarr-renovate[bot] b5dd6ae8ef chore(deps): update babel monorepo to >=7.29.7 (#5830)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 14:11:26 +00:00
homarr-renovate[bot] d540ad374a chore(deps): update pnpm to v10.34.0 (#5846)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 09:17:24 +00:00
homarr-renovate[bot] 6a971c1986 chore(deps): update mantine monorepo to ^9.2.2 (#5844)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-02 04:13:28 +00:00
homarr-crowdin[bot] d86fe3ddf0 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-06-02 02:23:34 +00:00
homarr-renovate[bot] 06db8960a7 chore(deps): update turbo monorepo to ^2.9.15 (#5841)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 23:12:09 +00:00
homarr-renovate[bot] a5719acf34 chore(deps): update dependency tmp@<=0.2.3 to >=0.2.7 (#5848)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 21:06:21 +00:00
homarr-renovate[bot] d1d597c8e1 chore(deps): update testcontainers-node monorepo to ^12.0.1 (#5845)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-01 16:48:41 +00:00
homarr-renovate[bot] 657e42e4bb chore(deps): update dependency mysql2 to v3.22.4 (#5839)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 18:12:08 +00:00
homarr-renovate[bot] 256ebcb864 chore(deps): update dependency oxlint to ^1.67.0 (#5837)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 16:18:04 +00:00
homarr-renovate[bot] 72e86dee01 chore(deps): update dependency ioredis to v5.11.0 (#5838)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 14:53:18 +00:00
homarr-renovate[bot] b7910b16e0 chore(deps): update dependency oxfmt to ^0.52.0 (#5836)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 11:08:29 +00:00
homarr-renovate[bot] 5a83bb6e39 chore(deps): update dependency dayjs to ^1.11.21 (#5835)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-31 08:54:20 +00:00
homarr-renovate[bot] 433b66582d chore(deps): update dependency hono@<4.6.5 to >=4.12.23 (#5828)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-30 09:25:22 +00:00
homarr-renovate[bot] acbcb6818b chore(deps): update dependency react-error-boundary to ^6.1.2 (#5829)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-30 06:54:05 +00:00
homarr-crowdin[bot] 07d991a0a1 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-05-30 05:27:45 +00:00
homarr-renovate[bot] 92364e9574 chore(deps): update tanstack-query monorepo to ^5.100.14 (#5813)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-29 21:15:51 +00:00
254 changed files with 17722 additions and 3208 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.64.0
- 1.62.0
- 1.61.0
- 1.60.1-beta.1
+24
View File
@@ -1,3 +1,27 @@
## [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
+1
View File
@@ -18,6 +18,7 @@
"@dnd-kit/modifiers": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:",
"@gfazioli/mantine-onboarding-tour": "catalog:",
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
@@ -18,7 +18,12 @@ export const CustomMantineProvider = ({
const manager = useColorSchemeManager();
return (
<DirectionProvider>
<MantineProvider defaultColorScheme={defaultColorScheme} colorSchemeManager={manager} theme={theme}>
<MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={theme}
cssVariablesResolver={v8CssVariablesResolver}
>
{children}
</MantineProvider>
</DirectionProvider>
@@ -17,10 +17,13 @@ import {
import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
const getWebSocketProtocol = () => {
// window is not defined on server side
@@ -56,6 +59,20 @@ export function TRPCReactProvider(props: PropsWithChildren) {
queries: {
staleTime: 5 * 1000,
},
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
},
},
}),
);
@@ -1,11 +1,14 @@
import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
@@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) {
{t("subtitle")}
</Text>
</Stack>
{appEnv.DEMO_MODE && (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Alert>
)}
<Card w={64 * 6} maw="90vw">
<LoginForm
providers={env.AUTH_PROVIDERS}
@@ -38,6 +38,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
// eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => {
@@ -2,7 +2,8 @@
import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Group, Menu, ScrollArea } from "@mantine/core";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import {
IconBox,
@@ -11,6 +12,7 @@ import {
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconPlug,
IconPlus,
IconReplace,
IconResize,
@@ -36,14 +38,16 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
if (!hasChangeAccess) {
if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />;
}
@@ -51,11 +55,15 @@ export const BoardContentHeaderActions = () => {
<>
{isEditMode && <AddMenu />}
<EditModeMenu />
<EditModeMenu demoMode={demoMode ?? false} />
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
{!demoMode && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
)}
<SelectBoardsMenu />
</>
@@ -67,6 +75,7 @@ const AddMenu = () => {
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions();
@@ -108,6 +117,10 @@ const AddMenu = () => {
});
}, [openAppSelectModal, createItem]);
const handleAddIntegration = useCallback(() => {
openIntegrationSelectModal({});
}, [openIntegrationSelectModal]);
return (
<Menu position="bottom-end">
<Menu.Target>
@@ -127,6 +140,10 @@ const AddMenu = () => {
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<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 board = useRequiredBoard();
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(() => {
if (isEditMode) return saveBoard(board);
if (isEditMode) {
if (demoMode) return discardDemoChanges();
return saveBoard(board);
}
open();
}, [board, isEditMode, saveBoard, open]);
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
return (
<HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
<OnboardingTour.Target id="board-edit-mode">
<HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
</OnboardingTour.Target>
);
};
@@ -183,27 +210,31 @@ const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
<OnboardingTour.Target id="board-switcher">
<Box>
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
</Box>
</OnboardingTour.Target>
);
};
@@ -244,7 +275,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return; // Allow to reload in development
if (env.NODE_ENV === "development") return;
event.preventDefault();
event.returnValue = true;
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions";
@@ -33,7 +33,12 @@ export const BoardMantineProvider = ({
});
return (
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
<MantineProvider
defaultColorScheme={defaultColorScheme}
theme={theme}
colorSchemeManager={colorSchemeManager}
cssVariablesResolver={v8CssVariablesResolver}
>
{children}
</MantineProvider>
);
@@ -11,6 +11,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourProvider } from "~/components/onboarding/board-tour";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import type { Params } from "./(content)/_creator";
@@ -20,14 +21,21 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
if (!hasSession) return <>{children}</>;
return <BoardTourProvider>{children}</BoardTourProvider>;
};
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
getInitialBoardAsync: getInitialBoard,
withTour = false,
}: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({
params,
@@ -61,14 +69,16 @@ export const createBoardLayout = <TParams extends Params>({
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
<BoardTourWrapper hasSession={withTour && !!session}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourWrapper>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
@@ -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 { Button, Card, Stack, Text } from "@mantine/core";
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { Button, Stack, Text } from "@mantine/core";
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { InitStepCard } from "../../_components/init-step-card";
export const InitFinish = async () => {
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
const tFinish = await getScopedI18n("init.step.finish");
return (
<Card w={64 * 6} maw="90vw">
<InitStepCard>
<Stack>
<Text>{tFinish("description")}</Text>
{firstBoard ? (
<InternalLinkButton
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
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>
)}
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
{tFinish("action.goToBoard", { name: "dashboard" })}
</InternalLinkButton>
{isProviderEnabled("credentials") && (
<InternalLinkButton
@@ -51,7 +39,7 @@ export const InitFinish = async () => {
{tFinish("action.docs")}
</ExternalLinkButton>
</Stack>
</Card>
</InitStepCard>
);
};
@@ -1,6 +1,6 @@
"use client";
import { Button, Card, Stack, TextInput } from "@mantine/core";
import { Button, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4";
@@ -10,6 +10,8 @@ import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
import { InitStepCard } from "../../_components/init-step-card";
export const InitGroup = () => {
const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
@@ -34,7 +36,7 @@ export const InitGroup = () => {
};
return (
<Card w={64 * 6} maw="90vw">
<InitStepCard>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<TextInput
@@ -48,6 +50,6 @@ export const InitGroup = () => {
</Button>
</Stack>
</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 { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
interface FileInfoCardProps {
file: FileWithPath;
onRemove: () => void;
@@ -13,7 +15,7 @@ interface FileInfoCardProps {
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return (
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
<InitStepCard withBorder={false}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group>
<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} />
</ActionIcon>
</Group>
</Card>
</InitStepCard>
);
};
@@ -1,13 +1,14 @@
"use client";
import { startTransition, useState } from "react";
import { Card, Stack } from "@mantine/core";
import { Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { InitialOldmarrImport } from "@homarr/old-import/components";
import { InitStepCard } from "../../_components/init-step-card";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
@@ -20,7 +21,7 @@ export const InitImport = () => {
if (!file) {
return (
<Card w={64 * 12 + 8} maw="90vw">
<InitStepCard>
<ImportDropZone
loading={isPending}
updateFile={(file) => {
@@ -40,12 +41,12 @@ export const InitImport = () => {
});
}}
/>
</Card>
</InitStepCard>
);
}
return (
<Stack mb="sm">
<Stack w="100%" mb="sm">
<FileInfoCard file={file} onRemove={() => setFile(null)} />
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
</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";
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 type { z } from "zod/v4";
@@ -13,6 +13,8 @@ import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { settingsInitSchema } from "@homarr/validation/settings";
import { InitStepCard } from "../../_components/init-step-card";
export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n();
@@ -29,8 +31,8 @@ export const InitSettings = () => {
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<Card w={64 * 12 + 8} maw="90vw">
<Stack w="100%">
<InitStepCard>
<Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text>
@@ -38,8 +40,8 @@ export const InitSettings = () => {
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
</Stack>
</Stack>
</Card>
<Card w={64 * 12 + 8} maw="90vw">
</InitStepCard>
<InitStepCard>
<Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
@@ -62,7 +64,7 @@ export const InitSettings = () => {
/>
</Stack>
</Stack>
</Card>
</InitStepCard>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{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 { getMantineColor } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
return (
<Card w={64 * 6} maw="90vw">
<InitStepCard>
<Stack>
<Text>{tStart("description")}</Text>
@@ -27,6 +28,6 @@ export const InitStart = async () => {
{tStart("action.importOldmarr")}
</InitStartButton>
</Stack>
</Card>
</InitStepCard>
);
};
@@ -1,15 +1,15 @@
"use client";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { Button, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { userInitSchema } from "@homarr/validation/user";
export const InitUserForm = () => {
@@ -59,12 +59,10 @@ export const InitUserForm = () => {
>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<CustomPasswordInput
withPasswordRequirements
label={t("field.password.label")}
{...form.getInputProps("password")}
<UserCreatePasswordFields
passwordInputProps={form.getInputProps("password")}
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
/>
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
<Button type="submit" fullWidth loading={isPending}>
{t("action.create")}
</Button>
@@ -1,11 +1,10 @@
import { Card } from "@mantine/core";
import { InitStepCard } from "../../_components/init-step-card";
import { InitUserForm } from "./init-user-form";
export const InitUser = () => {
return (
<Card w={64 * 6} maw="90vw">
<InitStepCard>
<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 { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import";
import { InitIntegrations } from "./_steps/integrations/init-integrations";
import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user";
@@ -23,6 +25,7 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
user: InitUser,
group: InitGroup,
settings: InitSettings,
integrations: InitIntegrations,
finish: InitFinish,
};
@@ -35,7 +38,7 @@ export default async function InitPage() {
return (
<Box mih="100dvh">
<Center>
<Stack align="center" mt="xl">
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
@@ -25,6 +26,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { Analytics } from "~/components/layout/analytics";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -11,6 +11,7 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
@@ -45,9 +46,11 @@ export default async function AppsPage(props: AppsPageProps) {
title={t("page.list.title")}
primaryAction={
canCreate ? (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
<TourTarget id="manage-apps-create">
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
</MobileAffixButton>
</TourTarget>
) : undefined
}
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
@@ -56,11 +59,13 @@ export default async function AppsPage(props: AppsPageProps) {
>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
<TourTarget id="manage-apps-list">
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
</TourTarget>
)}
</ManagePageLayout>
);
@@ -28,6 +28,7 @@ import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
@@ -42,18 +43,26 @@ export default async function ManageBoardsPage() {
return (
<ManagePageLayout
title={t("title")}
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
primaryAction={
canCreateBoards ? (
<TourTarget id="manage-boards-create">
<CreateBoardButton />
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateBoards}
>
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
{boards.length > 0 && (
<Grid>
{boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} />
</GridCol>
))}
</Grid>
<TourTarget id="manage-boards-list">
<Grid>
{boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} />
</GridCol>
))}
</Grid>
</TourTarget>
)}
</ManagePageLayout>
);
@@ -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";
import { startTransition, useState } from "react";
import { useRouter } from "next/navigation";
import {
Alert,
Anchor,
Button,
Checkbox,
Collapse,
@@ -16,7 +16,7 @@ import {
Text,
TextInput,
} 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 { clientApi } from "@homarr/api/client";
@@ -27,6 +27,7 @@ import type { IntegrationKind } from "@homarr/definitions";
import {
getAllSecretKindOptions,
getIconUrl,
getIntegrationApiKeyUrl,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
@@ -45,9 +46,13 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
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(
@@ -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 secretKinds = getAllSecretKindOptions(searchParams.kind);
const secretKinds = getAllSecretKindOptions(kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const router = useRouter();
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) {
// Placeholder Url, replaced with origin of the secret Url on submit
url = "http://localhost";
}
const form = useZodForm(formSchema, {
initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind),
name: initialName ?? getIntegrationName(kind),
url,
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",
})),
attemptSearchEngineCreation: true,
hasApp: false,
appHref: url,
attemptSearchEngineCreation: !isOnboarding,
hasApp: !isOnboarding,
appHref: isOnboarding ? null : url,
appId: null,
},
});
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({
const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({
async onSuccess() {
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 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
: 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 app = hasApp
@@ -106,49 +153,21 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
: {
name: values.name,
href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(searchParams.kind),
iconUrl: getIconUrl(kind),
description: null,
pingUrl: url,
}
: undefined;
await createIntegrationAsync(
{
kind: searchParams.kind,
...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"),
});
},
},
{ kind, ...values, url, app },
{ onSuccess: onMutationSuccess, onError: onMutationError },
);
};
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
const integrationCategories = integrationDefs[kind].category.flat();
const supportsSearchEngine =
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
return (
<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>
</Alert>
)}
<ApiKeySettingsLink kind={kind} url={form.values.url} />
</Stack>
</Fieldset>
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
{supportsSearchEngine && (
{!isOnboarding && supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(searchParams.kind),
kind: getIntegrationName(kind),
})}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
/>
)}
<AppForm form={form} canCreateApps={canCreateApps} />
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
{onCancel ? (
<Button variant="default" onClick={onCancel}>
{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}>
{t("integration.testConnection.action.create")}
</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<
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 { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import type { integrationCreateSchema } from "@homarr/validation/integration";
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 {
searchParams: Promise<
Partial<z.infer<typeof integrationCreateSchema>> & {
kind: IntegrationKind;
}
>;
searchParams: Promise<{
kind?: string;
url?: string;
name?: string;
}>;
}
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);
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");
@@ -45,7 +57,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
<IntegrationAvatar kind={currentKind} size="md" />
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
</Group>
<NewIntegrationForm searchParams={searchParams} />
<IntegrationNewFormWrapper kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
</Stack>
</Container>
</>
@@ -1,5 +1,4 @@
import { Fragment } from "react";
import type { PropsWithChildren } from "react";
import { redirect } from "next/navigation";
import {
AccordionControl,
@@ -8,12 +7,8 @@ import {
ActionIcon,
ActionIconGroup,
Anchor,
Button,
Divider,
Group,
Menu,
MenuDropdown,
MenuTarget,
Stack,
Table,
TableTbody,
@@ -23,7 +18,7 @@ import {
TableTr,
Text,
} 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 { api } from "@homarr/api/server";
@@ -34,13 +29,12 @@ import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
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 { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { env } from "~/env";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
import classes from "./page.module.css";
interface IntegrationsPageProps {
@@ -67,41 +61,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
title={t("page.list.title")}
primaryAction={
canCreateIntegrations ? (
<ManageMobilePrimaryAction>
<IntegrationSelectMenu>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</IntegrationSelectMenu>
</ManageMobilePrimaryAction>
<TourTarget id="manage-integrations-create">
<MobileAffixButton component={Link} href="/manage/integrations/new">
{t("action.create")}
</MobileAffixButton>
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateIntegrations}
>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
<TourTarget id="manage-integrations-list">
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</TourTarget>
</ManagePageLayout>
);
}
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 {
integrations: RouterOutputs["integration"]["all"];
activeTab?: IntegrationKind;
@@ -134,12 +109,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
return (
<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}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group>
<Text>{getIntegrationName(kind)}</Text>
<CountBadge count={integrations.length} />
<CountBadge count={kindIntegrations.length} />
</Group>
</AccordionControl>
<AccordionPanel>
@@ -152,7 +127,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</TableTr>
</TableThead>
<TableTbody>
{integrations.map((integration) => (
{kindIntegrations.map((integration) => (
<TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd>
<TableTd>
@@ -173,7 +148,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup>
)}
</Group>
@@ -184,9 +159,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</Table>
<Stack gap="xs" hiddenFrom="md">
{integrations.map((integration, index) => (
{kindIntegrations.map((integration, integrationIndex) => (
<Fragment key={integration.id}>
{index !== 0 && <Divider />}
{integrationIndex !== 0 && <Divider />}
<Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text>
@@ -201,7 +176,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup>
)}
</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 { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar");
@@ -45,11 +46,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"),
icon: IconHomeFilled,
href: "/manage",
"data-onboarding-tour-id": "manage-welcome",
},
{
icon: IconLayoutDashboardFilled,
href: "/manage/boards",
label: t("items.boards"),
"data-onboarding-tour-id": "manage-boards",
},
{
icon: IconBox,
@@ -59,12 +62,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: {
strokeWidth: 2.5,
},
"data-onboarding-tour-id": "manage-apps",
},
{
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session,
"data-onboarding-tour-id": "manage-integrations",
},
{
icon: IconSearch,
@@ -74,17 +79,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: {
strokeWidth: 2.5,
},
"data-onboarding-tour-id": "manage-search-engines",
},
{
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
"data-onboarding-tour-id": "manage-medias",
},
{
icon: IconUserFilled,
label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-users",
items: [
{
label: t("items.users.items.manage"),
@@ -153,6 +161,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/settings",
icon: IconSettingsFilled,
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-settings",
},
{
label: t("items.help.label"),
@@ -191,11 +200,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
];
return (
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const shell = (
<ClientShell hasNavigation>
<MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
if (!session) return shell;
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
}
@@ -1,23 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
import {
IconCategoryPlus,
IconDots,
IconFileText,
IconPlayerPlay,
IconPlayerStop,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap } from "@homarr/docker/shared";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -27,25 +30,30 @@ import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
const createColumns = (
t: TranslationFunction,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
{
accessorKey: "name",
header: t("docker.field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs">
<Group gap="xs" wrap="nowrap">
<Avatar
variant="outline"
radius="lg"
radius="sm"
size="md"
styles={{ image: { objectFit: "contain" } }}
src={row.original.iconUrl}
>
{row.original.name.at(0)?.toUpperCase()}
</Avatar>
<Text>{renderedCellValue}</Text>
<Text truncate="end" maw={500}>
{renderedCellValue}
</Text>
</Group>
);
},
@@ -76,11 +84,11 @@ const createColumns = (
maxSize: 200,
Cell({ renderedCellValue, cell }) {
return (
<Box maw={200}>
<Text truncate="end" title={cell.row.original.image}>
{renderedCellValue}
</Text>
</Box>
<Tooltip label={cell.row.original.image} multiline maw={400}>
<Box maw={200}>
<Text truncate="end">{renderedCellValue}</Text>
</Box>
</Tooltip>
);
},
},
@@ -95,18 +103,79 @@ const createColumns = (
);
},
},
{
id: "cpuUsage",
accessorKey: "cpuUsage",
size: 120,
header: t("docker.field.stats.cpu.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const cpuUsageA = safeValue(rowA.original.cpuUsage);
const cpuUsageB = safeValue(rowB.original.cpuUsage);
return cpuUsageA - cpuUsageB;
},
Cell({ row }) {
const cpuUsage = safeValue(row.original.cpuUsage);
return (
<Text size="sm" c={cpuUsageColor(cpuUsage, row.original.state)}>
{cpuUsage.toFixed(2)}%
</Text>
);
},
},
{
id: "memoryUsage",
accessorKey: "memoryUsage",
size: 140,
header: t("docker.field.stats.memory.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const memoryUsageA = safeValue(rowA.original.memoryUsage);
const memoryUsageB = safeValue(rowB.original.memoryUsage);
return memoryUsageA - memoryUsageB;
},
Cell({ row }) {
const bytesUsage = safeValue(row.original.memoryUsage);
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
</Text>
);
},
},
];
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
initialData: { containers, timestamp },
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const relativeTime = useTimeAgo(data.timestamp);
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
await utils.docker.getContainers.invalidate();
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
const table = useTranslatedMantineReactTable({
data: data.containers,
enableDensityToggle: false,
@@ -114,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
enableColumnFilters: false,
enablePagination: false,
enableRowSelection: true,
enableRowActions: true,
positionActionsColumn: "last",
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
@@ -124,36 +195,15 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
autoFocus: true,
},
initialState: { density: "xs", showGlobalFilter: true },
renderTopToolbarCustomActions: () => {
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
await utils.docker.getContainers.invalidate();
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
return (
<Button
variant="default"
rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()}
loading={isPending}
>
{tDocker("action.refresh.label")}
</Button>
);
},
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
renderTopToolbarCustomActions: () => (
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
{tDocker("action.refresh.label")}
</Button>
),
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return (
@@ -181,28 +231,104 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
);
}
const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [
{ action: "start", icon: IconPlayerPlay, color: "green" },
{ action: "stop", icon: IconPlayerStop, color: "red" },
{ action: "restart", icon: IconRotateClockwise, color: "orange" },
{ action: "remove", icon: IconTrash, color: "red" },
];
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
const t = useScopedI18n("docker.action");
const router = useRouter();
const utils = clientApi.useUtils();
const { openModal } = useModalAction(AddDockerAppToHomarr);
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
clientApi.docker[`${action}All`].useMutation({
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`${action}.notification.error.title`),
message: t(`${action}.notification.error.message`),
});
},
});
const mutations = {
start: useContainerAction("start"),
stop: useContainerAction("stop"),
restart: useContainerAction("restart"),
remove: useContainerAction("remove"),
};
return (
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" onClick={(e) => e.stopPropagation()}>
<IconDots size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconFileText size="1rem" />}
onClick={() => router.push(createContainerLogsPath(container))}
>
{t("logs.label")}
</Menu.Item>
<Menu.Divider />
{containerActions.map(({ action, icon: Icon, color }) => (
<Menu.Item
key={action}
leftSection={<Icon size="1rem" />}
color={color}
onClick={() => mutations[action].mutate({ ids: [container.id] })}
>
{t(`${action}.label`)}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
leftSection={<IconCategoryPlus size="1rem" />}
color="red"
onClick={() => openModal({ selectedContainers: [container] })}
>
{t("addToHomarr.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
interface ContainerActionBarProps {
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
selectedContainers: DockerContainer[];
}
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const handleClick = () => {
openModal({
selectedContainers,
});
};
const selectedIds = selectedContainers.map((container) => container.id);
return (
<Group gap="xs">
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
{containerActions.map(({ action, icon, color }) => (
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
))}
<Button
leftSection={<IconCategoryPlus />}
color="red"
onClick={() => openModal({ selectedContainers })}
variant="light"
>
{t("addToHomarr.label")}
</Button>
</Group>
@@ -0,0 +1,10 @@
"use client";
import dynamic from "next/dynamic";
export const ClientSideDockerLogsTerminal = dynamic(
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
{
ssr: false,
},
);
@@ -0,0 +1,47 @@
import { notFound } from "next/navigation";
import { Box, Group } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import "@xterm/xterm/css/xterm.css";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { ClientSideDockerLogsTerminal } from "./client";
interface DockerContainerLogsPageProps {
params: Promise<{ containerId: string }>;
searchParams: Promise<{ name?: string }>;
}
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) {
notFound();
}
const { containerId } = await params;
const { name } = await searchParams;
try {
await api.docker.logs({ id: containerId, tail: 1 });
} catch {
notFound();
}
return (
<>
<Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb
dynamicMappings={new Map([[containerId, name ?? containerId]])}
nonInteractable={[containerId]}
/>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal containerId={containerId} />
</Box>
</>
);
}
@@ -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 { 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<{
level: LogLevel;
setLevel: (level: LogLevel) => void;
activeLevels: LogLevel[];
fontSize: number;
setFontSize: (size: number) => void;
} | null>(null);
interface LogContextProviderProps extends PropsWithChildren {
@@ -18,9 +31,19 @@ interface LogContextProviderProps extends PropsWithChildren {
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
const [level, setLevel] = useState(defaultLevel);
const [fontSize, setFontSizeRaw] = useState(readStoredFontSize);
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 = () => {
@@ -13,6 +13,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
import { ClientSideTerminalComponent } from "./client";
import { LogFontSizeSlider } from "./font-size-slider";
import { LogLevelSelection } from "./level-selection";
import { LogContextProvider } from "./log-context";
@@ -38,7 +39,10 @@ export default async function LogsManagementPage() {
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
<Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb />
<LogLevelSelection />
<Group gap="md" wrap="nowrap">
<LogFontSizeSlider />
<LogLevelSelection />
</Group>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent />
@@ -13,9 +13,11 @@ import classes from "./terminal.module.css";
export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels } = useLogContext();
const { activeLevels, fontSize } = useLogContext();
const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
clientApi.log.subscribe.useSubscription(
{
levels: activeLevels,
@@ -26,7 +28,6 @@ export const TerminalComponent = () => {
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
},
onError(err) {
// This makes sense as logging might cause an infinite loop
alert(err);
},
},
@@ -43,22 +44,30 @@ export const TerminalComponent = () => {
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize,
});
terminalRef.current.open(ref.current);
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(() => {
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
terminalRef.current?.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>;
};
@@ -0,0 +1,53 @@
"use client";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export const ResetTours = () => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
onSuccess() {
showSuccessNotification({
title: t("resetSuccess.title"),
message: t("resetSuccess.message"),
});
void utils.user.getTourStatus.invalidate();
},
});
const tours = [
{ key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
{ key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
];
return (
<Stack gap="xs">
<Group gap="xs">
{tours.map(({ key, completed }) => (
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
</Badge>
))}
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Button
leftSection={<IconRefresh size={16} />}
variant="light"
size="sm"
w="fit-content"
onClick={() => resetTours()}
loading={isPending}
>
{t("reset")}
</Button>
</Stack>
);
};
@@ -13,6 +13,7 @@ import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { ResetTours } from "./_components/_reset-tours";
interface Props {
params: Promise<{
@@ -87,6 +88,13 @@ export default async function EditUserPage(props: Props) {
</Box>
</Group>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
<ResetTours />
</Stack>
)}
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
const session = await auth();
const t = await getI18n();
const tUser = await getScopedI18n("management.page.user");
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form";
@@ -23,7 +23,8 @@ export default async function UserSecurityPage(props: Props) {
.getById({
userId: params.userId,
})
.catch(catchTrpcNotFound);
.catch(catchTrpcNotFound)
.catch(catchTrpcUnauthorized);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
@@ -1,20 +1,7 @@
"use client";
import { startTransition, useCallback, useState } from "react";
import {
Badge,
Button,
Card,
Group,
PasswordInput,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -24,10 +11,11 @@ import type { GroupPermissionKey } from "@homarr/definitions";
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { userPasswordSchema } from "@homarr/validation/user";
@@ -167,18 +155,10 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
<form>
<Card p="xl">
<Stack gap="md">
<CustomPasswordInput
withPasswordRequirements
label={tUserField("password.label")}
<UserCreatePasswordFields
variant="filled"
withAsterisk
{...securityForm.getInputProps("password")}
/>
<PasswordInput
label={tUserField("passwordConfirm.label")}
variant="filled"
withAsterisk
{...securityForm.getInputProps("confirmPassword")}
passwordInputProps={securityForm.getInputProps("password")}
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
/>
</Stack>
</Card>
@@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { createMetaTitle } from "~/metadata";
@@ -38,14 +39,18 @@ export default async function UsersPage() {
title={t("list.title")}
primaryAction={
credentialsProviderEnabled ? (
<MobileAffixButton component={Link} href="/manage/users/create">
{t("create.title")}
</MobileAffixButton>
<TourTarget id="manage-users-create">
<MobileAffixButton component={Link} href="/manage/users/create">
{t("create.title")}
</MobileAffixButton>
</TourTarget>
) : undefined
}
floatingPrimaryAction={credentialsProviderEnabled}
>
<UserListComponent initialUserList={userList} />
<TourTarget id="manage-users-list">
<UserListComponent initialUserList={userList} />
</TourTarget>
</ManagePageLayout>
);
}
@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
title(t) {
@@ -1,11 +1,11 @@
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 { useZodForm } from "@homarr/form";
import type { GridStack } from "@homarr/gridstack";
import { createModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
@@ -16,9 +16,7 @@ interface InnerProps {
}
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tCommon = useScopedI18n("common");
const t = useI18n();
// Keep track of the maximum width based on the x offset
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
const form = useZodForm(
z.object({
@@ -38,7 +36,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
height: innerProps.item.height,
},
onValuesChange(values, previous) {
// Update the maximum width when the x offset changes
if (values.xOffset !== previous.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 }}>
<NumberInput
label={t("item.moveResize.field.xOffset.label")}
data-autofocus
min={0}
max={innerProps.columnCount - 1}
{...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")} />
</Grid.Col>
</Grid>
<Group justify="end">
<Button variant="subtle" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit">{tCommon("action.saveChanges")}</Button>
</Group>
<ModalFormFooter onCancel={actions.closeModal} />
</Stack>
</form>
);
@@ -106,5 +99,5 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
defaultTitle(t) {
return t("item.moveResize.title");
},
size: "lg",
size: modalSizeForm,
});
@@ -1,14 +1,14 @@
import { useMemo, useState } from "react";
import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } 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 { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -86,32 +86,31 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
};
return (
<Stack>
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch />}
placeholder={`${t("item.create.search")}...`}
data-autofocus
onKeyDown={(event) => {
// 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);
}
}}
/>
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
onSearchKeyDown={(event) => {
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.map((item) => (
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
))}
</Grid>
</Stack>
{filteredItems.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
);
}).withOptions({
defaultTitle: (t) => t("item.create.title"),
size: "xl",
size: modalSizeSelect,
});
const WidgetItem = ({
@@ -130,46 +129,58 @@ const WidgetItem = ({
const t = useI18n();
return (
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
<Card h="100%">
<Stack justify="space-between" h="100%">
<Stack gap="xs">
<Center>
<item.icon />
</Center>
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
{item.name}
</Text>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
{item.description}
</Text>
</Stack>
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
{t(`item.create.addToBoard`)}
</Button>
</Stack>
</Card>
</Grid.Col>
<Card
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{item.name}
</Text>
</Group>
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
</Tooltip>
<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 {
integrations: IntegrationKind[];
mt: string;
}
const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) => {
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
if (integrations.length === 0) {
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 moreCount = integrations.length - countToShow;
return (
<Center mt={mt}>
<Group gap={2} mt="auto">
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
@@ -195,6 +206,6 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps)
)}
</Group>
</Tooltip.Group>
</Center>
</Group>
);
};
@@ -0,0 +1,155 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -0,0 +1,116 @@
.wrapper {
position: absolute;
left: 10px;
right: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
z-index: 200;
pointer-events: auto;
opacity: 0;
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
.panel {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
}
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
backdrop-filter: blur(8px);
overflow: hidden;
}
.nameSection {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 0;
}
.widgetIcon {
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.integrations {
flex-shrink: 0;
}
.integrationAvatar {
border: none;
}
.metadata {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-left: auto;
}
.metadataItem {
@mixin dark {
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
}
@mixin light {
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
}
display: flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.metadataLabel {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.metadataValue {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.wrapper {
display: none;
}
}
@@ -0,0 +1,86 @@
"use client";
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Translator } from "./widget-hover-metadata";
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
import classes from "./widget-hover-overlay.module.css";
interface WidgetHoverOverlayProps {
item: SectionItem;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode();
const settings = useSettings();
const t = useI18n();
const { definition } = widgetImports[item.kind];
const WidgetIcon = definition.icon;
const translate = ((key: string) => t(key as never)) as Translator;
const displayName = resolveWidgetDisplayName(item, settings, translate);
const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id),
);
const metadataItems = collectWidgetHoverMetadata(
item,
settings,
connectedIntegrations.map((integration) => integration.kind),
translate,
);
const hasIntegrations = connectedIntegrations.length > 0;
const hasMetadata = metadataItems.length > 0;
if (isEditMode) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
<Text className={classes.name} title={displayName}>
{displayName}
</Text>
</div>
{hasIntegrations && (
<Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar
src={getIconUrl(integration.kind)}
size={20}
radius="sm"
className={classes.integrationAvatar}
/>
</Tooltip>
))}
</Avatar.Group>
)}
{hasMetadata && (
<Group className={classes.metadata} gap={4}>
{metadataItems.map((metadataItem) => (
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
<span className={classes.metadataLabel}>{metadataItem.label}</span>
<span className={classes.metadataValue}>{metadataItem.value}</span>
</div>
))}
</Group>
)}
</div>
</div>
);
};
@@ -1,8 +1,8 @@
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { Stack, TextInput } from "@mantine/core";
import { z } from "zod/v4";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface Category {
@@ -36,13 +36,10 @@ export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps
>
<Stack>
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit">{innerProps.submitLabel}</Button>
</Group>
<ModalFormFooter onCancel={actions.closeModal} submitLabel={innerProps.submitLabel} />
</Stack>
</form>
);
}).withOptions({});
}).withOptions({
size: modalSizeForm,
});
@@ -2,8 +2,12 @@ import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
@@ -11,6 +15,7 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -18,7 +23,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].sort((itemA, itemB) => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset;
}
@@ -30,7 +35,7 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
))}
</>
);
@@ -39,12 +44,12 @@ export const SectionContent = () => {
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
const Item = ({ item, innerRef }: ItemProps) => {
const Item = ({ item, innerRef, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<GridStackItem
key={item.id}
@@ -59,6 +64,7 @@ const Item = ({ item, innerRef }: ItemProps) => {
minWidth={minWidth}
minHeight={minHeight}
>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
@@ -1,10 +1,10 @@
"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 { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { TextMultiSelect } from "@homarr/ui";
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
@@ -31,7 +31,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
})}
>
<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
label={t("section.dynamic.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
@@ -48,14 +48,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
}
{...form.getInputProps("borderColor")}
/>
<Group justify="end">
<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>
<ModalFormFooter onCancel={actions.closeModal} />
</Stack>
</form>
);
@@ -63,5 +56,5 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
defaultTitle(t) {
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 { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
@@ -25,11 +26,15 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<DesktopSearchInput />
<TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<UserButton />
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<Spotlight />
@@ -0,0 +1,11 @@
"use client";
import type { ReactNode } from "react";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box } from "@mantine/core";
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
<OnboardingTour.Target id={id}>
<Box data-tour-target={id}>{children}</Box>
</OnboardingTour.Target>
);
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core";
import { Link } from "@homarr/ui";
@@ -9,9 +10,15 @@ import { Link } from "@homarr/ui";
export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
if (!id) return <>{children}</>;
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
const NavLinkHref = (props: NavigationLinkHref) => {
const pathname = usePathname();
return props.external ? (
const tourId = props["data-onboarding-tour-id"];
const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
) : (
<NavLink
@@ -19,26 +26,29 @@ const NavLinkHref = (props: NavigationLinkHref) => {
label={props.label}
leftSection={props.icon}
href={props.href}
active={pathname === props.href}
active={pathname === props.href || pathname.startsWith(`${props.href}/`)}
/>
);
return <TourTarget id={tourId}>{link}</TourTarget>;
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const pathname = usePathname();
const isActive = props.items.some((item) => item.href === pathname);
return (
const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
const nav = (
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
{props.items.map((item) => (
<NavLinkHref key={item.label} {...item} />
))}
</NavLink>
);
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
};
interface CommonNavigationLinkProps {
label: string;
icon: ReactNode;
"data-onboarding-tour-id"?: string;
}
interface NavigationLinkHref extends CommonNavigationLinkProps {
@@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
return null;
}
const { icon: TablerIcon, iconProps, ...props } = link;
const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link;
const Icon =
typeof TablerIcon === "string" ? (
<Image src={TablerIcon} w={20} h={20} />
@@ -38,17 +38,20 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
if ("items" in props) {
clientLink = {
...props,
"data-onboarding-tour-id": tourId,
items: props.items
.filter((item) => !item.hidden)
.map((item) => {
const { "data-onboarding-tour-id": itemTourId, ...rest } = item;
return {
...item,
...rest,
"data-onboarding-tour-id": itemTourId,
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
};
}),
} as ClientNavigationLink;
} else {
clientLink = props as ClientNavigationLink;
clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink;
}
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
})}
@@ -63,6 +66,7 @@ interface CommonNavigationLinkProps {
icon: TablerIcon | string;
iconProps?: TablerIconProps;
hidden?: boolean;
"data-onboarding-tour-id"?: string;
}
interface NavigationLinkHref extends CommonNavigationLinkProps {
@@ -58,19 +58,23 @@ export const DynamicBreadcrumb = ({
}
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
const mappedValue = dynamicMappings?.get(pathnamePart);
if (mappedValue) {
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{mappedValue}</Text>;
}
return (
<Anchor key={href} href={href}>
{mappedValue}
</Anchor>
);
}
if (nonInteractable?.includes(pathnamePart)) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
}
if (dynamicMappings?.has(pathnamePart)) {
return (
<Anchor key={href} href={href}>
{dynamicMappings.get(pathnamePart)}
</Anchor>
);
}
return (
<Anchor key={href} href={href}>
{t(`${translationKey}.label` as TranslationKeys)}
@@ -0,0 +1,108 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
export const BoardTourProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("onboardingTour.board");
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
const handleEnd = () => {
utils.user.getTourStatus.setData(undefined, {
completedManageTour: tourStatus?.completedManageTour ?? false,
completedBoardTour: true,
});
completeTour({ tour: "board" });
};
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [
{
id: "board-search",
title: t("welcome.title"),
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink(
"/docs/getting-started/after-the-installation",
"#creating-your-first-board",
)}
/>
),
},
{
id: "board-edit-mode",
title: t("editMode.title"),
requiresChangeAccess: true,
content: (
<TourStepContent
description={t("editMode.description")}
documentationHref={createDocumentationLink(
"/docs/getting-started/after-the-installation",
"#arrange-and-organize-your-board",
)}
/>
),
},
{
id: "board-settings",
title: t("settings.title"),
requiresChangeAccess: true,
content: (
<TourStepContent
description={t("settings.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
{
id: "board-switcher",
title: t("switcher.title"),
content: (
<TourStepContent
description={t("switcher.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
{
id: "board-user-menu",
title: t("userMenu.title"),
content: (
<TourStepContent
description={t("userMenu.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
/>
),
},
];
return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[];
}, [hasChangeAccess, t]);
return (
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
{children}
</TourShell>
);
};
@@ -0,0 +1,220 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { useMediaQuery } from "@mantine/hooks";
import {
IconAffiliateFilled,
IconAppsFilled,
IconLayoutDashboardFilled,
IconPlus,
IconUserFilled,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
interface ManageTourProviderProps extends PropsWithChildren {
isAdmin: boolean;
}
const stepRoutes: Record<string, string> = {
"manage-welcome": "/manage",
"manage-boards-list": "/manage/boards",
"manage-boards-create": "/manage/boards",
"manage-apps-list": "/manage/apps",
"manage-apps-create": "/manage/apps",
"manage-integrations-list": "/manage/integrations",
"manage-integrations-create": "/manage/integrations",
"manage-users-list": "/manage/users",
"manage-users-create": "/manage/users",
};
const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
withReveal: false,
popoverProps: {
position: "bottom",
middlewares: { shift: { padding: 16 }, flip: true },
},
};
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const t = useScopedI18n("onboardingTour.manage");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const isMobile = useMediaQuery("(max-width: 48em)");
const pathname = usePathname();
const router = useRouter();
const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname);
const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname);
const [tourActive, setTourActive] = useState(false);
const [tourDismissed, setTourDismissed] = useState(false);
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
useEffect(() => {
if (tourDismissed) return;
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
setTourActive(true);
}
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
useEffect(() => {
if (!isManageSection) {
setTourActive(false);
}
}, [isManageSection]);
useEffect(() => {
const wasCompleted = wasManageTourCompletedRef.current;
const isCompleted = tourStatus?.completedManageTour;
if (wasCompleted === true && isCompleted === false) {
setTourDismissed(false);
}
wasManageTourCompletedRef.current = isCompleted;
}, [tourStatus?.completedManageTour]);
const handleEnd = useCallback(() => {
setTourDismissed(true);
setTourActive(false);
utils.user.getTourStatus.setData(undefined, {
completedManageTour: true,
completedBoardTour: tourStatus?.completedBoardTour ?? false,
});
completeTour({ tour: "manage" });
router.push("/manage");
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
{
id: "manage-welcome",
title: t("welcome.title"),
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink("/docs/category/management")}
/>
),
},
{
id: "manage-boards-list",
title: t("boardsList.title"),
content: (
<TourStepContent
description={t("boardsList.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
icon={<IconLayoutDashboardFilled size={18} />}
/>
),
},
{
id: "manage-boards-create",
title: t("boardsCreate.title"),
content: (
<TourStepContent
description={t("boardsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-apps-list",
title: t("appsList.title"),
content: (
<TourStepContent
description={t("appsList.description")}
documentationHref={createDocumentationLink("/docs/management/apps")}
icon={<IconAppsFilled size={18} />}
/>
),
},
{
id: "manage-apps-create",
title: t("appsCreate.title"),
content: (
<TourStepContent
description={t("appsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/apps")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-integrations-list",
title: t("integrationsList.title"),
content: (
<TourStepContent
description={t("integrationsList.description")}
documentationHref={createDocumentationLink("/docs/management/integrations")}
icon={<IconAffiliateFilled size={18} />}
/>
),
},
{
id: "manage-integrations-create",
title: t("integrationsCreate.title"),
content: (
<TourStepContent
description={t("integrationsCreate.description")}
documentationHref={createDocumentationLink("/docs/management/integrations")}
icon={<IconPlus size={18} />}
/>
),
},
{
id: "manage-users-list",
title: t("usersList.title"),
adminOnly: true,
focusRevealProps: usersStepFocusRevealProps,
content: (
<TourStepContent
description={t("usersList.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
icon={<IconUserFilled size={18} />}
/>
),
},
{
id: "manage-users-create",
title: t("usersCreate.title"),
adminOnly: true,
focusRevealProps: usersStepFocusRevealProps,
content: (
<TourStepContent
description={t("usersCreate.description")}
documentationHref={createDocumentationLink("/docs/management/users")}
icon={<IconPlus size={18} />}
/>
),
},
];
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
}, [isAdmin, t]);
return (
<TourShell
steps={steps}
started={tourActive}
onEnd={handleEnd}
stepRoutes={stepRoutes}
position={{ base: "bottom", sm: "right" }}
>
{children}
</TourShell>
);
};
@@ -0,0 +1,197 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import type { OnboardingTourController, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
import type { FloatingPosition } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
interface TourShellProps extends PropsWithChildren {
steps: OnboardingTourStep[];
started: boolean;
onEnd: () => void;
stepRoutes?: Record<string, string>;
position?: FloatingPosition | Record<string, FloatingPosition>;
}
type TourController = OnboardingTourController;
const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0;
const stepAtOffset = (controller: TourController, offset: number) => {
const index = stepIndexFromController(controller) + offset;
return controller.tour[index];
};
const pollForElement = (targetId: string, callback: () => void) => {
const interval = setInterval(() => {
const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
if (targetElement) {
clearInterval(interval);
callback();
}
}, 50);
const timeout = setTimeout(() => clearInterval(interval), 5000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
};
const isTypingTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
};
interface TourForwardButtonProps {
label: string;
onClick: () => void;
}
const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
return (
<Button size="sm" variant="light" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
{label}
</Button>
);
};
interface TourDoneButtonProps {
label: string;
onClick: () => void;
}
const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
return (
<Button size="sm" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
{label}
</Button>
);
};
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
const t = useI18n();
const router = useRouter();
const forwardActionRef = useRef<(() => void) | null>(null);
const navigateAndAdvance = useCallback(
(currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
if (!stepRoutes || !targetStepId) {
advance();
return;
}
const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
const targetRoute = stepRoutes[targetStepId];
if (targetRoute && targetRoute !== currentRoute) {
router.push(targetRoute);
pollForElement(targetStepId, advance);
} else {
advance();
}
},
[router, stepRoutes],
);
const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
forwardActionRef.current = action;
return action;
}, []);
useEffect(() => {
if (!started) {
forwardActionRef.current = null;
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
event.preventDefault();
forwardActionRef.current?.();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [started]);
return (
<OnboardingTour
tour={steps}
started={started}
onOnboardingTourEnd={onEnd}
withStepper={false}
header={(controller) => (
<>
<Group justify="center" gap={4} py={4}>
<Text size="sm" fw={600}>
{stepIndexFromController(controller) + 1}
</Text>
<Text size="sm" c="dimmed">
/
</Text>
<Text size="sm" c="dimmed">
{controller.tour.length}
</Text>
</Group>
{stepIndexFromController(controller) === 0 && (
<Center py="xs">
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
</Center>
)}
</>
)}
nextStepNavigation={(controller) => {
const nextStep = stepAtOffset(controller, 1);
const action = bindForwardAction(controller, () => {
navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
});
return <TourForwardButton label={t("onboardingTour.next")} onClick={action} />;
}}
endStepNavigation={(controller) => {
const action = bindForwardAction(controller, () => controller.endTour());
return <TourDoneButton label={t("onboardingTour.done")} onClick={action} />;
}}
prevStepNavigation={(controller) => (
<Button
size="sm"
variant="default"
onClick={() => {
const prevStep = stepAtOffset(controller, -1);
navigateAndAdvance(controller.currentStep?.id, prevStep?.id, () => controller.prevStep());
}}
>
{t("onboardingTour.prev")}
</Button>
)}
skipNavigation={(controller) => (
<Button size="sm" variant="subtle" color="gray" onClick={() => controller.skipTour()}>
{t("onboardingTour.skip")}
</Button>
)}
withPrevButton
focusRevealProps={{
disableTargetInteraction: true,
popoverProps: {
position: position ?? { base: "bottom", sm: "right" },
width: 420,
shadow: "xl",
radius: "lg",
middlewares: { shift: { padding: 16 }, flip: true },
},
}}
cutoutPadding={12}
cutoutRadius={12}
>
{children}
</OnboardingTour>
);
};
@@ -0,0 +1,39 @@
"use client";
import type { ReactNode } from "react";
import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface TourStepContentProps {
description: string;
documentationHref: string;
icon?: ReactNode;
}
export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
const t = useI18n();
return (
<Stack gap="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
{icon && (
<ThemeIcon variant="light" size="lg" radius="md" mt={2}>
{icon}
</ThemeIcon>
)}
<Text size="sm" lh={1.7} style={{ flex: 1 }}>
{description}
</Text>
</Group>
<Divider />
<Anchor href={documentationHref} target="_blank" size="xs" fw={500}>
<Group gap={4} wrap="nowrap">
<IconExternalLink size={14} />
{t("onboardingTour.learnMore")}
</Group>
</Anchor>
</Stack>
);
};
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
window.location.assign(logoutUrl);
return;
}
router.refresh();
router.push("/auth/login");
},
});
}, [logoutUrl, openModal, router]);
+1
View File
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});
+4
View File
@@ -102,6 +102,10 @@
overflow-y: auto;
}
.grid-stack > .grid-stack-item[data-type="item"] {
overflow: visible;
}
.grid-stack.grid-stack-animate {
transition: none;
}
+2 -2
View File
@@ -6,9 +6,9 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=10.33.4"
"pnpm": ">=10.34.1"
},
"packageManager": "pnpm@10.33.4",
"packageManager": "pnpm@10.34.1",
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
+20 -10
View File
@@ -19,6 +19,9 @@
<!-- Links -->
<p align="center">
<a href="https://demo.homarr.dev">
<strong>Live Demo 🎮</strong>
</a> •
<a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong>
</a> •
@@ -246,13 +249,20 @@
</a>
</td>
<td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg" alt="PeaNUT" width="90" height="90" />
<br/>
<p align="center">PeaNUT</p>
</a>
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
<br/>
<p align="center">Pi-hole</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
<br/>
@@ -293,15 +303,15 @@
<br/>
<p align="center">Radarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
<br/>
<p align="center">Readarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/>
@@ -342,15 +352,15 @@
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
<br/>
<p align="center">Tdarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
<br/>
+2
View File
@@ -31,6 +31,7 @@ describe("Onboarding", () => {
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
@@ -72,6 +73,7 @@ describe("Onboarding", () => {
name: externalGroupName,
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
+5
View File
@@ -51,4 +51,9 @@ export class OnboardingActions {
await this.page.waitForSelector("text=Analytics");
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 * 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 type { SqliteDatabase } from "../e2e-db";
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
}
public async assertFinishStepVisibleAsync() {
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
}
}
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.63.0",
"version": "1.64.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -60,9 +60,9 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=10.33.4"
"pnpm": ">=10.34.1"
},
"packageManager": "pnpm@10.33.4",
"packageManager": "pnpm@10.34.1",
"pnpm": {
"onlyBuiltDependencies": [
"@tree-sitter-grammars/tree-sitter-yaml",
@@ -76,25 +76,25 @@
"tree-sitter-json"
],
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.2",
"@babel/runtime@<7.26.10": ">=7.29.2",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"axios@>=1.0.0 <1.8.2": ">=1.17.0",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.12.22",
"hono@<4.6.5": ">=4.12.23",
"linkifyjs@<4.3.2": ">=4.3.3",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.6",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
"react-is": "^19.2.7",
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.5",
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
"tmp@<=0.2.3": ">=0.2.7",
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
+3 -1
View File
@@ -1,12 +1,14 @@
import { z } from "zod/v4";
import { createEnv } from "@homarr/core/infrastructure/env";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE,
},
});
+2 -2
View File
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
import { publicProcedure } from "../trpc";
import { isDemoMode, publicProcedure } from "../trpc";
export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER) {
if (env.ENABLE_DOCKER || isDemoMode) {
return await next();
}
throw new TRPCError({
@@ -4,7 +4,11 @@ import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
import {
dockerContainersRequestHandler,
getContainerLogsAsync,
streamContainerLogsAsync,
} from "@homarr/request-handler/docker";
import { dockerMiddleware } from "../../middlewares/docker";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
@@ -104,6 +108,84 @@ export const dockerRouter = createTRPCRouter({
const innerHandler = dockerContainersRequestHandler.handler({});
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) => {
+2 -1
View File
@@ -1,7 +1,7 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
@@ -13,4 +13,5 @@ export const infoRouter = createTRPCRouter({
version: packageJson.version,
};
}),
isDemoMode: publicProcedure.query(() => isDemoMode),
});
@@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,11 +16,9 @@ import {
integrations,
integrationSecrets,
integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import {
getIconUrl,
getIntegrationKindsByCategory,
getPermissionsWithParents,
integrationCategories,
@@ -33,6 +33,7 @@ import {
integrationSavePermissionsSchema,
integrationUpdateSchema,
} from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
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";
const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
@@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({
},
};
})
.sort(
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
@@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({
},
};
})
.sort(
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
@@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({
},
};
})
.sort(
.toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
@@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({
url: input.url,
});
if (
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),
});
}
// Media request search is a first-class Spotlight action. Do not create a search engine for it.
}),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
@@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({
user,
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupIntegrationPermission
@@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({
},
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
@@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({
const integrationInstance = await createIntegrationAsync(ctx.integration);
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 {
integrationId: string;
value: string;
@@ -640,35 +770,6 @@ interface AddSecretInput {
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) => {
await db.insert(integrationSecrets).values({
kind: input.kind,
@@ -76,6 +76,9 @@ const nextSteps: Partial<Record<OnboardingStep, Partial<Record<OnboardingStep, N
settings: true,
},
settings: {
integrations: true,
},
integrations: {
finish: true,
},
};
@@ -1,12 +1,57 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { onboarding } from "@homarr/db/schema";
import { onboardingSteps } from "@homarr/definitions";
import { createId } from "@homarr/common";
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 { 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";
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({
currentStep: publicProcedure.query(async ({ ctx }) => {
return await getOnboardingOrFallbackAsync(ctx.db);
@@ -33,4 +78,249 @@ export const onboardRouter = createTRPCRouter({
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 { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
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";
const logger = createLogger({ module: "searchEngineRouter" });
@@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({
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
.requiresPermission("search-engine-create")
.input(searchEngineManageSchema)
@@ -13,6 +13,14 @@ import { dockerRouter } from "../../docker/docker-router";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/request-handler/docker", () => ({
getContainerLogsAsync: async () => {
await Promise.resolve();
return "logs";
},
streamContainerLogsAsync: async () => {
await Promise.resolve();
return () => undefined;
},
dockerContainersRequestHandler: {
handler: () => ({
getCachedOrUpdatedDataAsync: async () => {
@@ -65,6 +73,8 @@ const validInputs: {
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 },
};
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 { createId } from "@homarr/common";
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 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", () => {
test("with full access should return an integration by id", async () => {
const db = createDb();
@@ -208,7 +329,7 @@ describe("create should create a new integration", () => {
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 caller = integrationRouter.createCaller({
db,
@@ -243,12 +364,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
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",
);
expect(dbSearchEngine).toBeUndefined();
});
test("with create integration access should create a new integration with new linked app", async () => {
+50 -8
View File
@@ -81,13 +81,7 @@ describe("initUser should initialize the first user", () => {
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
});
it.each([
["aB2%"], // too short
["abc123DEF"], // does not contain special characters
["abcDEFghi+"], // does not contain numbers
["ABC123+/-"], // does not contain lowercase
["abc123+/-"], // does not contain uppercase
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
@@ -103,7 +97,55 @@ describe("initUser should initialize the first user", () => {
confirmPassword: password,
});
await expect(actAsync()).rejects.toThrow("passwordRequirements");
await expect(actAsync()).rejects.toThrow();
});
it("should accept passwords without complexity requirements", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await caller.initUser({
username: "test",
password: "abc123DEF",
confirmPassword: "abc123DEF",
});
const user = await db.query.users.findFirst({
columns: {
id: true,
},
});
expect(user).toBeDefined();
});
it("should accept passwords with special characters like LoveHomarr<3", async () => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
await caller.initUser({
username: "test",
password: "LoveHomarr<3",
confirmPassword: "LoveHomarr<3",
});
const user = await db.query.users.findFirst({
columns: {
id: true,
},
});
expect(user).toBeDefined();
});
});
+47 -2
View File
@@ -32,6 +32,7 @@ import {
import { convertIntersectionToZodObject } from "../schema-merger";
import {
createTRPCRouter,
isDemoMode,
onboardingProcedure,
permissionRequiredProcedure,
protectedProcedure,
@@ -278,6 +279,8 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
@@ -304,6 +307,8 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
completedManageTour: true,
completedBoardTour: true,
},
where: eq(users.id, input.userId),
});
@@ -542,7 +547,7 @@ export const userRouter = createTRPCRouter({
.set({
pingIconsEnabled: input.pingIconsEnabled,
})
.where(eq(users.id, ctx.session.user.id));
.where(eq(users.id, input.id));
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
@@ -603,8 +608,48 @@ export const userRouter = createTRPCRouter({
.set({
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">) => {
+1
View File
@@ -31,4 +31,5 @@ export const widgetRouter = createTRPCRouter({
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
});
+51
View File
@@ -0,0 +1,51 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { UpsSummary } from "@homarr/integrations/types";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
return innerHandler.subscribe((summaries) => {
emit.next({
integrationId: integration.id,
summaries,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
+17 -7
View File
@@ -18,6 +18,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" });
@@ -87,6 +88,20 @@ export const createCallerFactory = t.createCallerFactory;
*/
export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE;
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Mutations are disabled in demo mode",
});
}
return next({ ctx });
});
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/**
* Public (unauthed) procedure
*
@@ -94,19 +109,14 @@ export const createTRPCRouter = t.router;
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = t.procedure;
export const publicProcedure = baseProcedure;
/**
* Reusable middleware that enforces users are logged in before running the
* procedure
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
@@ -121,7 +131,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
/**
* Procedure that requires a specific permission
+5 -1
View File
@@ -1,7 +1,11 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
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 = (
@@ -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,
"tag": "0038_add_user_ddg_bangs",
"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
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff. Show more