Compare commits

...
Author SHA1 Message Date
Thomas 3b1b4c76a4 Merge branch 'dev' into feat/recover-beszel 2026-06-10 18:26:53 +02:00
Thomas 5d1f268438 feat: SQLite backup & restore with WASM preview (#5887) 2026-06-10 18:26:17 +02:00
Thomas Camlong 78f2a25f2b fix(widgets): restore lucide-react and mantine-datatable dependencies
Beszel widgets import these packages directly but they were missing
from package.json while still listed in pnpm-lock.yaml, causing
frozen-lockfile Docker builds to fail.
2026-06-10 18:21:16 +02:00
Thomas 484b8cf9f1 fix: gracefully skip widgets with unknown kind instead of crashing (#5915) 2026-06-10 18:19:13 +02:00
Thomas Camlong 13c9f768fd chore: update pnpm-lock.yaml 2026-06-10 18:13:58 +02:00
Thomas Camlong 8920be7fd3 fix(i18n): disable createMessagesDeclaration to fix typecheck
The auto-generated en.d.json.ts was stale (missing beszel keys),
causing 67 TS2345 errors. Additionally, with ~2,367 leaf keys at
depth 10, the generated declaration triggers TS type complexity
limits (known next-intl issue #2296).

Disable the feature in next.config.ts, delete the stale file,
and remove its .gitignore entry. Key/namespace validation is
preserved via raw JSON import types.
2026-06-10 18:13:51 +02:00
Thomas Camlong 4b0c69e6a3 fix(beszel): resolve lint errors in widget and API files
Remove unused BeszelStatsData import from beszel router.
Replace non-null assertions with safe fallbacks across beszel
widget files (format.ts, chart.tsx, system-grid, system-stats).
2026-06-10 18:13:38 +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
Thomas Camlong 79b3e5cc18 fix: narrow possibly undefined system in beszel test
TypeScript reports 'system' is possibly undefined since it comes from
array indexing. Replace expect().toBeDefined() with an explicit throw
guard so the type narrows for subsequent property accesses.
2026-06-08 23:57:44 +02:00
Thomas Camlong 27521a6cfd fix(beszel): resolve lint errors in integration test
Remove unused BeszelSystem import and replace non-null assertions
with optional chaining in test expectations.
2026-06-08 22:42:12 +02:00
Thomas Camlong 5e4867edaf refactor(beszel): replace hand-rolled formatUptime with dayjs humanize 2026-06-08 22:40:04 +02:00
Thomas Camlong 540ad5a2c1 refactor(beszel): remove dead exports and deduplicate formatters
- Remove unused getBandwidthRates, getContainerNetworkRate, formatMB
- Remove unused alertColorMap from colors.ts
- Remove duplicate chartAxisFormatters.percentPrecise (identical to .percent)
- Make chartAxisFormatters.percent reference formatPercent directly
2026-06-08 22:34:21 +02: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
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
Thomas Camlong 5588761db7 fix(beszel): remove non-null assertion in stats request handler 2026-06-08 16:29:34 +02:00
ajnart 4d886bf980 style: auto-fix lint and format 2026-06-08 14:20:38 +00:00
Thomas Camlong dfec430b86 fix(beszel): escape backslashes before quotes in filter values
Fixes CodeQL warning: incomplete string escaping. Backslashes must be
escaped first to prevent `\'` injection via `\` in user input.
2026-06-08 16:19:11 +02:00
Thomas Camlong 466c439ba2 chore: remove beszel dev environment files from PR 2026-06-08 16:17:10 +02:00
Thomas Camlong 4c36c9cff1 fix(beszel): prevent recharts negative dimension warnings
Add minWidth: 0 and overflow: hidden to ChartPanel (CSS Grid item)
and minWidth: 0 to BeszelAreaChart. Prevents ResponsiveContainer from
computing negative width/height in grid/flex layouts before first paint.
2026-06-08 16:16:18 +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
Thomas Camlong cfa510dbf9 fix(beszel-mock): improve mock data fidelity and demo coverage
- Use correct time intervals per period (not always 1min)
- Return newest-first sort order matching real Beszel API
- Pass systemId into time series records
- Stable container sets per system (deterministic from system def)
- Fix pi-cluster arch to aarch64
- Clamp cpu/mem percentages to [0, 100]
- Add beszelSystemStats widget to demo seed
2026-06-08 16:14:22 +02:00
ajnart 52d5c050b6 style: auto-fix lint and format 2026-06-08 14:09:13 +00:00
Thomas Camlong 43f1613215 feat(beszel): add mock integration support for demo mode
Wire Beszel widgets into the mock integration pipeline so they work
with UNSAFE_ENABLE_MOCK_INTEGRATION and DEMO_MODE for demos.
2026-06-08 16:06:49 +02:00
Thomas Camlong bb96ca354f docs(beszel): document Beszel API types and field semantics
Add JSDoc comments to all Beszel PocketBase API types explaining the
cryptic short field names (cpu, mu, du, dr, ns, b, etc.), their units
(bytes, bytes/s, percentages), and the distinction between ns/nr
(all-interface traffic) vs b (public interface bandwidth).

Also documents the mapToSystemRow mapping and chart data extractors
so future readers understand the data flow without needing Beszel
source code.
2026-06-08 16:00:15 +02:00
ajnart c1bd6cccaa style: auto-fix lint and format 2026-06-08 13:58:40 +00:00
Thomas Camlong 2bf014ba43 fix(beszel): use bandwidth field (b) for public interface traffic
In Beszel, ns/nr are all-interface traffic (loopback, docker bridges)
while b is public interface bandwidth. Use b with ns/nr fallback for
both system stats and container network charts to match what the
grid/table display via netBytes.
2026-06-08 15:57:18 +02:00
ajnart 515b285ef7 style: auto-fix lint and format 2026-06-08 13:54:14 +00:00
Thomas Camlong 5eebfb7719 chore(dev): update beszel dev environment config
Update docker-compose and gitignore for beszel development setup.
2026-06-08 15:51:49 +02:00
Thomas Camlong 7f1638fec6 chore(beszel): add translations and dependency updates
Add i18n keys for beszel system stats chart labels (memory cache,
disk IO series) and update workspace dependencies.
2026-06-08 15:51:39 +02:00
Thomas Camlong 6b1cc0fd6b refactor(beszel): migrate widgets to shared utilities and fix perf
Update all four Beszel widgets to use extracted shared hooks, chart
helpers, colors, and formatters. Fix chart data regressions: restore
byte-to-GB conversion for memory/disk, use raw byte values for diskIO
instead of incorrect multiplication, and use ns/nr fields for network
instead of the optional b field.

Add drag-and-drop performance fixes: disable pointer events and
WebSocket subscriptions during edit mode to prevent re-renders while
dragging widgets on the board.
2026-06-08 15:51:30 +02:00
Thomas Camlong 3131687229 refactor(beszel): extract shared hooks, chart helpers, and tooltip
Create useBeszelSystemsSubscription and useBeszelFilteredSystems hooks
to eliminate duplicated subscription + cache-update + filtering logic
across grid/table/alerts. Add BeszelAreaChart wrapper and chart data
hooks (useSystemChartData, useDockerChartData) to reduce ~90 lines of
repetitive useMemo transforms in system-stats. Add portal tooltip with
mousemove listener gated on active state to prevent unnecessary
re-renders during drag-and-drop.
2026-06-08 15:51:17 +02:00
Thomas Camlong 59d44933cf refactor(beszel): centralize shared constants and formatters
Move alertColorMap and containerColors to _shared/colors.ts, extract
statusOptions to _shared/options.ts, and rename formatBytes to
formatByteRate for clarity since it formats byte rates not raw bytes.
2026-06-08 15:51:05 +02:00
Thomas Camlong 5cf9b1b752 refactor(beszel): deduplicate BeszelSystemRow type definition
Consolidate BeszelSystemRow into @homarr/integrations as the single
source of truth. Both request-handler and widgets now re-export from
there instead of maintaining separate identical copies that could
silently diverge.
2026-06-08 15:50:55 +02:00
Thomas Camlong 48dcf65c4e chore(dev): colocate beszel agent data with docker-compose
Move beszel_agent_data, beszel_data, and beszel_socket directories
into development/ alongside beszel.docker-compose.yml. Add .gitignore
for volatile data (dbs, sockets) while keeping the fingerprint.
2026-06-08 12:56:16 +02:00
Thomas Camlong 6757fbe956 feat(integrations): recover beszel integration from worktree
Recovered all beszel integration files from the local worktree
at /Users/ajnart/.cursor/worktrees/homarr/pwxp that were built
and tested with the homarr:beszel docker image.
2026-06-08 12:54:30 +02: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
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
Releases Homarr ff1e4c1381 chore(release): 1.63.0 [skip ci] 2026-05-29 21:07:15 +00:00
homarr-releases[bot] 21bb20ce0c chore(release): automatic release v1.63.0 2026-05-29 21:06:26 +00:00
Releases Homarr 92b75cb924 chore(release): 1.62.0 [skip ci] 2026-05-22 20:48:36 +00:00
Meier Lukas 1ea008b71c chore(release): automatic release v1.62.0 2026-05-22 22:47:05 +02:00
285 changed files with 22282 additions and 2654 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
+1 -2
View File
@@ -66,8 +66,7 @@ e2e/shared/tmp
#personal backgrounds
apps/nextjs/public/images/background.png
# next-intl
en.d.json.ts
# personal agent config
AGENTS.md
+64
View File
@@ -1,3 +1,67 @@
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
### Features
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
### Bug Fixes
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
### Features
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
### Bug Fixes
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
### Performance Improvements
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
### Features
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
### Bug Fixes
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
### Performance Improvements
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
### Bug Fixes
+16 -4
View File
@@ -5,18 +5,30 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const homarrVersion = (() => {
for (const candidate of ["../../package.json", "./package.json"]) {
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
if (pkg.name === "homarr") return pkg.version as string;
} catch {}
}
return "unknown";
})();
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin({
experimental: {
createMessagesDeclaration: "../../packages/translation/src/lang/en.json",
},
requestConfig: "../../packages/translation/src/request.ts",
});
const nextConfig: NextConfig = {
env: {
HOMARR_VERSION: homarrVersion,
},
output: "standalone",
reactStrictMode: true,
// react compiler breaks mantine-react-table, so disabled for now
@@ -27,7 +39,7 @@ const nextConfig: NextConfig = {
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
+10
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",
@@ -71,10 +72,16 @@
"clsx": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"adm-zip": "catalog:",
"better-sqlite3": "catalog:",
"isomorphic-dompurify": "catalog:",
"jszip": "catalog:",
"jotai": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
@@ -84,6 +91,7 @@
"react-error-boundary": "catalog:",
"react-simple-code-editor": "catalog:",
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"swagger-ui-react": "catalog:",
"use-deep-compare-effect": "catalog:",
@@ -92,6 +100,8 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "catalog:",
"@types/better-sqlite3": "catalog:",
"@types/chroma-js": "catalog:",
"@types/node": "catalog:",
"@types/prismjs": "catalog:",
BIN
View File
Binary file not shown.
@@ -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;
@@ -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,20 @@
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 { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitSqliteRestore } from "./sqlite-restore";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
return (
<Card w={64 * 6} maw="90vw">
<InitStepCard>
<Stack>
<Text>{tStart("description")}</Text>
@@ -26,7 +30,8 @@ export const InitStart = async () => {
>
{tStart("action.importOldmarr")}
</InitStartButton>
{isSqlite && <InitSqliteRestore />}
</Stack>
</Card>
</InitStepCard>
);
};
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Button, Collapse, Stack, Text } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const InitSqliteRestore = () => {
const t = useScopedI18n("init.step.start.action");
const [expanded, setExpanded] = useState(false);
if (expanded) {
return (
<Stack gap="sm">
<Button
variant="subtle"
size="xs"
onClick={() => setExpanded(false)}
>
{t("backToOptions")}
</Button>
<Text size="sm" c="dimmed">
{t("restoreSqliteDescription")}
</Text>
<DatabaseRestoreFlow variant="standalone" />
</Stack>
);
}
return (
<Button
onClick={() => setExpanded(true)}
variant="default"
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
>
{t("restoreSqlite")}
</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">
+3
View File
@@ -1,9 +1,11 @@
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";
import "mantine-datatable/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
@@ -25,6 +27,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>
+24 -1
View File
@@ -10,6 +10,7 @@ import {
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDatabaseExport,
IconDirectionsFilled,
IconGitFork,
IconHelpSquareRoundedFilled,
@@ -28,6 +29,7 @@ import {
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
@@ -36,6 +38,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 +48,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 +64,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 +81,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: {
strokeWidth: 2.5,
},
"data-onboarding-tour-id": "manage-search-engines",
},
{
icon: IconPhotoFilled,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
"data-onboarding-tour-id": "manage-medias",
},
{
icon: IconUserFilled,
label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-users",
items: [
{
label: t("items.users.items.manage"),
@@ -146,6 +156,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.backup"),
icon: IconDatabaseExport,
href: "/manage/tools/backup",
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
},
],
},
{
@@ -153,6 +169,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 +208,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
];
return (
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const shell = (
<ClientShell hasNavigation>
<MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
if (!session) return shell;
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
}
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
export const BackupExportCard = () => {
const t = useScopedI18n("management.page.tool.backup.export");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const handleExport = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/backup/export");
if (!response.ok) {
const errorMessage = await response.json().then(
(data) => data.error,
() => `Server returned ${response.status}`,
);
throw new Error(errorMessage ?? t("error"));
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
setShowConfirm(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("error"));
} finally {
setLoading(false);
}
};
return (
<Card withBorder>
<Stack gap="sm">
<Group gap="sm">
<IconDatabaseExport size={24} />
<Title order={4}>{t("title")}</Title>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{showConfirm ? (
<DangerousActionConfirmation
title={t("confirm.title")}
warningTitle={t("confirm.warningTitle")}
warningBody={t("confirm.warningBody")}
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
submitLabel={t("confirm.submit")}
submitIcon={<IconDownload size={16} />}
cancelLabel={t("confirm.cancel")}
onConfirm={handleExport}
onCancel={() => setShowConfirm(false)}
disabled={loading}
color="orange"
/>
) : (
<Group>
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
{t("button")}
</Button>
</Group>
)}
</Stack>
</Card>
);
};
@@ -0,0 +1,27 @@
"use client";
import { Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const BackupImportCard = () => {
const t = useScopedI18n("management.page.tool.backup.import");
return (
<Card withBorder>
<Stack gap="md">
<Group gap="sm">
<IconDatabaseImport size={24} />
<Title order={4}>{t("title")}</Title>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<DatabaseRestoreFlow />
</Stack>
</Card>
);
};
@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
export async function generateMetadata() {
const session = await auth();
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
return {};
}
const t = await getScopedI18n("management.page.tool.backup");
return {
title: createMetaTitle(t("metaTitle")),
};
}
export default async function BackupPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
notFound();
}
const t = await getScopedI18n("management.page.tool.backup");
return (
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
);
}
@@ -1,23 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
import {
IconCategoryPlus,
IconDots,
IconFileText,
IconPlayerPlay,
IconPlayerStop,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap } from "@homarr/docker/shared";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -27,25 +30,30 @@ import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
const createColumns = (
t: TranslationFunction,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
{
accessorKey: "name",
header: t("docker.field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs">
<Group gap="xs" wrap="nowrap">
<Avatar
variant="outline"
radius="lg"
radius="sm"
size="md"
styles={{ image: { objectFit: "contain" } }}
src={row.original.iconUrl}
>
{row.original.name.at(0)?.toUpperCase()}
</Avatar>
<Text>{renderedCellValue}</Text>
<Text truncate="end" maw={500}>
{renderedCellValue}
</Text>
</Group>
);
},
@@ -76,11 +84,11 @@ const createColumns = (
maxSize: 200,
Cell({ renderedCellValue, cell }) {
return (
<Box maw={200}>
<Text truncate="end" title={cell.row.original.image}>
{renderedCellValue}
</Text>
</Box>
<Tooltip label={cell.row.original.image} multiline maw={400}>
<Box maw={200}>
<Text truncate="end">{renderedCellValue}</Text>
</Box>
</Tooltip>
);
},
},
@@ -95,13 +103,57 @@ const createColumns = (
);
},
},
{
id: "cpuUsage",
accessorKey: "cpuUsage",
size: 120,
header: t("docker.field.stats.cpu.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const cpuUsageA = safeValue(rowA.original.cpuUsage);
const cpuUsageB = safeValue(rowB.original.cpuUsage);
return cpuUsageA - cpuUsageB;
},
Cell({ row }) {
const cpuUsage = safeValue(row.original.cpuUsage);
return (
<Text size="sm" c={cpuUsageColor(cpuUsage, row.original.state)}>
{cpuUsage.toFixed(2)}%
</Text>
);
},
},
{
id: "memoryUsage",
accessorKey: "memoryUsage",
size: 140,
header: t("docker.field.stats.memory.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const memoryUsageA = safeValue(rowA.original.memoryUsage);
const memoryUsageB = safeValue(rowB.original.memoryUsage);
return memoryUsageA - memoryUsageB;
},
Cell({ row }) {
const bytesUsage = safeValue(row.original.memoryUsage);
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
</Text>
);
},
},
];
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
initialData: { containers, timestamp },
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@@ -131,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
enableColumnFilters: false,
enablePagination: false,
enableRowSelection: true,
enableRowActions: true,
positionActionsColumn: "last",
positionToolbarAlertBanner: "top",
enableTableFooter: false,
enableBottomToolbar: false,
@@ -141,7 +195,10 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
autoFocus: true,
},
initialState: { density: "xs", showGlobalFilter: true },
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
renderTopToolbarCustomActions: () => (
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
{tDocker("action.refresh.label")}
@@ -174,28 +231,104 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
);
}
const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [
{ action: "start", icon: IconPlayerPlay, color: "green" },
{ action: "stop", icon: IconPlayerStop, color: "red" },
{ action: "restart", icon: IconRotateClockwise, color: "orange" },
{ action: "remove", icon: IconTrash, color: "red" },
];
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
const t = useScopedI18n("docker.action");
const router = useRouter();
const utils = clientApi.useUtils();
const { openModal } = useModalAction(AddDockerAppToHomarr);
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
clientApi.docker[`${action}All`].useMutation({
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`${action}.notification.error.title`),
message: t(`${action}.notification.error.message`),
});
},
});
const mutations = {
start: useContainerAction("start"),
stop: useContainerAction("stop"),
restart: useContainerAction("restart"),
remove: useContainerAction("remove"),
};
return (
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" onClick={(e) => e.stopPropagation()}>
<IconDots size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconFileText size="1rem" />}
onClick={() => router.push(createContainerLogsPath(container))}
>
{t("logs.label")}
</Menu.Item>
<Menu.Divider />
{containerActions.map(({ action, icon: Icon, color }) => (
<Menu.Item
key={action}
leftSection={<Icon size="1rem" />}
color={color}
onClick={() => mutations[action].mutate({ ids: [container.id] })}
>
{t(`${action}.label`)}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
leftSection={<IconCategoryPlus size="1rem" />}
color="red"
onClick={() => openModal({ selectedContainers: [container] })}
>
{t("addToHomarr.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
interface ContainerActionBarProps {
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
selectedContainers: DockerContainer[];
}
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const handleClick = () => {
openModal({
selectedContainers,
});
};
const selectedIds = selectedContainers.map((container) => container.id);
return (
<Group gap="xs">
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
{containerActions.map(({ action, icon, color }) => (
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
))}
<Button
leftSection={<IconCategoryPlus />}
color="red"
onClick={() => openModal({ selectedContainers })}
variant="light"
>
{t("addToHomarr.label")}
</Button>
</Group>
@@ -0,0 +1,10 @@
"use client";
import dynamic from "next/dynamic";
export const ClientSideDockerLogsTerminal = dynamic(
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
{
ssr: false,
},
);
@@ -0,0 +1,47 @@
import { notFound } from "next/navigation";
import { Box, Group } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import "@xterm/xterm/css/xterm.css";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { ClientSideDockerLogsTerminal } from "./client";
interface DockerContainerLogsPageProps {
params: Promise<{ containerId: string }>;
searchParams: Promise<{ name?: string }>;
}
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) {
notFound();
}
const { containerId } = await params;
const { name } = await searchParams;
try {
await api.docker.logs({ id: containerId, tail: 1 });
} catch {
notFound();
}
return (
<>
<Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb
dynamicMappings={new Map([[containerId, name ?? containerId]])}
nonInteractable={[containerId]}
/>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal containerId={containerId} />
</Box>
</>
);
}
@@ -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();
@@ -6,6 +6,7 @@ import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { createMetaTitle } from "~/metadata";
@@ -38,14 +39,18 @@ export default async function UsersPage() {
title={t("list.title")}
primaryAction={
credentialsProviderEnabled ? (
<MobileAffixButton component={Link} href="/manage/users/create">
{t("create.title")}
</MobileAffixButton>
<TourTarget id="manage-users-create">
<MobileAffixButton component={Link} href="/manage/users/create">
{t("create.title")}
</MobileAffixButton>
</TourTarget>
) : undefined
}
floatingPrimaryAction={credentialsProviderEnabled}
>
<UserListComponent initialUserList={userList} />
<TourTarget id="manage-users-list">
<UserListComponent initialUserList={userList} />
</TourTarget>
</ManagePageLayout>
);
}
@@ -0,0 +1,75 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
export async function GET() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (dbEnv.DRIVER !== "better-sqlite3") {
return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 });
}
const dbPath = dbEnv.URL as string | undefined;
if (!dbPath || !fs.existsSync(dbPath)) {
return NextResponse.json({ error: "Database file not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite");
let sourceDb: InstanceType<typeof Database> | null = null;
try {
sourceDb = new Database(dbPath, { readonly: true });
sourceDb.pragma("wal_checkpoint(TRUNCATE)");
sourceDb.exec(`VACUUM INTO '${tempPath}'`);
sourceDb.close();
sourceDb = null;
const dbBuffer = fs.readFileSync(tempPath);
const metadata = {
homarrVersion: process.env.HOMARR_VERSION ?? "unknown",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: env.SECRET_ENCRYPTION_KEY,
};
const zip = new AdmZip();
zip.addFile("db.sqlite", dbBuffer);
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2)));
const zipBuffer = zip.toBuffer();
const date = new Date().toISOString().split("T")[0];
const filename = `homarr-backup-${date}.zip`;
return new NextResponse(zipBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": zipBuffer.length.toString(),
},
});
} catch (error) {
console.error("[backup/export] Export failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 });
} finally {
sourceDb?.close();
try {
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
} catch (cleanupErr) {
console.error("[backup/export] Failed to clean up temp file:", cleanupErr);
}
}
}
@@ -0,0 +1,194 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { db } from "@homarr/db";
import { findMigrationsFolder } from "../shared";
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
const ALGORITHM = "aes-256-cbc";
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
if (importedKeyHex === currentKeyHex) return;
if (!HEX_KEY_REGEX.test(importedKeyHex)) {
throw new Error("Imported encryption key is not a valid 64-character hex string");
}
const oldKey = Buffer.from(importedKeyHex, "hex");
const newKey = Buffer.from(currentKeyHex, "hex");
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
integration_id: string;
kind: string;
value: string;
}[];
if (rows.length === 0) return;
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
const transaction = tempDb.transaction(() => {
for (const row of rows) {
const parts = row.value.split(".");
if (parts.length !== 2) {
throw new Error(
`Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`,
);
}
const [data, dataIv] = parts as [string, string];
try {
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
const newIv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv);
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
updateStmt.run(newValue, row.integration_id, row.kind);
} catch {
throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`);
}
}
});
transaction();
};
const isOnboardingActiveAsync = async (): Promise<boolean> => {
const onboardingRow = await db.query.onboarding.findFirst();
if (!onboardingRow) return false;
return onboardingRow.step === "start";
};
export async function POST(req: Request) {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
if (!isAdmin && !isOnboarding) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (dbEnv.DRIVER !== "better-sqlite3") {
return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 });
}
const dbPath = dbEnv.URL as string | undefined;
if (!dbPath) {
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
}
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
let zip: AdmZip;
try {
zip = new AdmZip(Buffer.from(arrayBuffer));
} catch {
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
}
const entries = zip.getEntries().map((e) => e.entryName);
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
if (missingEntries.length > 0) {
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
}
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
let tempDb: InstanceType<typeof Database> | null = null;
try {
const dbEntry = zip.getEntry("db.sqlite")!;
fs.writeFileSync(tempPath, dbEntry.getData());
tempDb = new Database(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
migrate(drizzleDb, { migrationsFolder });
const metadataEntry = zip.getEntry("metadata.json")!;
const metadata = JSON.parse(metadataEntry.getData().toString());
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
const secretCount = (
tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }
).count;
if (secretCount > 0 && !importedKey) {
throw new Error(
"Backup contains integration secrets but no encryption key. " +
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
);
}
if (importedKey) {
reEncryptSecrets(tempDb, importedKey);
}
tempDb.close();
tempDb = null;
fs.renameSync(tempPath, dbPath);
for (const suffix of ["-wal", "-shm"] as const) {
try {
const sidecar = `${dbPath}${suffix}`;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
} catch (cleanupErr) {
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
}
}
setTimeout(() => {
console.log("Database restored, restarting server...");
process.exit(0);
}, 500);
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
} catch (error) {
console.error("[backup/import] Restore failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
} finally {
tempDb?.close();
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// temp file may have been renamed already
}
}
}
}
@@ -0,0 +1,63 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { findMigrationsFolder } from "../shared";
interface JournalEntry {
idx: number;
version: string;
when: number;
tag: string;
breakpoints: boolean;
}
interface MigrationFile {
idx: number;
tag: string;
sql: string;
when: number;
}
export async function GET() {
try {
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
if (!fs.existsSync(journalPath)) {
return NextResponse.json({ error: "Migration journal not found" }, { status: 500 });
}
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] };
const migrations: MigrationFile[] = [];
for (const entry of journal.entries) {
const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`);
if (!fs.existsSync(sqlPath)) continue;
migrations.push({
idx: entry.idx,
tag: entry.tag,
sql: fs.readFileSync(sqlPath, "utf-8"),
when: entry.when,
});
}
return NextResponse.json(
{ total: migrations.length, migrations },
{
headers: {
"Cache-Control": "public, max-age=3600",
},
},
);
} catch (error) {
console.error("[backup/migrations] Failed to read migrations:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
}
}
+12
View File
@@ -0,0 +1,12 @@
import fs from "fs";
import path from "path";
const migrationsFolderCandidates = [
path.join(process.cwd(), "db/migrations/sqlite"),
path.join(process.cwd(), "packages/db/migrations/sqlite"),
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
export const findMigrationsFolder = () =>
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
@@ -0,0 +1,30 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
const wasmCandidates = [
path.join(process.cwd(), "public/sql-wasm.wasm"),
path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"),
path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"),
path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"),
];
let cachedWasm: Buffer | null = null;
export async function GET() {
if (!cachedWasm) {
const found = wasmCandidates.find((p) => fs.existsSync(p));
if (!found) {
return NextResponse.json({ error: "WASM binary not found" }, { status: 500 });
}
cachedWasm = fs.readFileSync(found);
}
return new NextResponse(cachedWasm, {
headers: {
"Content-Type": "application/wasm",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
@@ -0,0 +1,308 @@
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
const ALGORITHM = "aes-256-cbc";
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
const key = Buffer.from(keyHex, "hex");
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
};
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
const key = Buffer.from(keyHex, "hex");
const [data, dataIv] = value.split(".") as [string, string];
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
};
const createTestDb = (dbPath: string) => {
const sqlite = new Database(dbPath);
const db = drizzle(sqlite, { casing: DB_CASING });
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
return { db, sqlite };
};
describe("SQLite backup", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("export ZIP structure", () => {
it("should create a ZIP containing all required entries", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const backupPath = path.join(tmpDir, "db.backup.sqlite");
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
sqlite.close();
const dbBuffer = fs.readFileSync(backupPath);
const metadata = {
homarrVersion: "test",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: generateHexKey(),
};
const zip = new AdmZip();
zip.addFile("db.sqlite", dbBuffer);
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
const zipPath = path.join(tmpDir, "backup.zip");
zip.writeZip(zipPath);
const readBack = new AdmZip(zipPath);
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
const dbEntry = readBack.getEntry("db.sqlite");
expect(dbEntry).toBeDefined();
expect(dbEntry!.header.size).toBeGreaterThan(0);
});
it("should produce a valid SQLite database in the ZIP", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const backupPath = path.join(tmpDir, "db.backup.sqlite");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
sqlite.close();
const backupDb = new Database(backupPath, { readonly: true });
const tables = backupDb
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as { name: string }[];
expect(tables.map((t) => t.name)).toContain("board");
expect(tables.map((t) => t.name)).toContain("user");
expect(tables.map((t) => t.name)).toContain("integrationSecret");
backupDb.close();
});
});
describe("import ZIP validation", () => {
it("should detect missing required entries", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toEqual(["metadata.json"]);
});
it("should accept valid backup ZIP with all entries", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toHaveLength(0);
});
it("should accept legacy backup ZIP with encryption-key.txt", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toHaveLength(0);
});
});
describe("secret re-encryption", () => {
it("should re-encrypt secrets from old key to new key", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const oldKey = generateHexKey();
const newKey = generateHexKey();
const plaintext = "my-secret-password";
const encryptedValue = encryptWithKey(plaintext, oldKey);
sqlite.exec(`
INSERT INTO "integration" ("id", "name", "url", "kind")
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
`);
sqlite.exec(`
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
`);
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
expect(decryptedBefore).toBe(plaintext);
const oldKeyBuf = Buffer.from(oldKey, "hex");
const newKeyBuf = Buffer.from(newKey, "hex");
const rows = sqlite
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
.all() as { integration_id: string; kind: string; value: string }[];
const updateStmt = sqlite.prepare(
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
);
for (const row of rows) {
const [data, dataIv] = row.value.split(".") as [string, string];
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
const newIv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
updateStmt.run(newValue, row.integration_id, row.kind);
}
const updatedRow = sqlite
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
.get("int-1") as { value: string };
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
sqlite.close();
});
it("should skip re-encryption when keys match", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const sameKey = generateHexKey();
const plaintext = "my-secret";
const encryptedValue = encryptWithKey(plaintext, sameKey);
sqlite.exec(`
INSERT INTO "integration" ("id", "name", "url", "kind")
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
`);
sqlite.exec(`
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
`);
const rowBefore = sqlite
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
.get("int-1") as { value: string };
expect(rowBefore.value).toBe(encryptedValue);
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
expect(decrypted).toBe(plaintext);
sqlite.close();
});
});
describe("VACUUM INTO consistency", () => {
it("should produce a consistent copy via VACUUM INTO", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
sqlite.exec(`
INSERT INTO "app" ("id", "name", "icon_url")
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
`);
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
const backupDb = new Database(backupPath, { readonly: true });
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
expect(app).toBeDefined();
expect(app!.name).toBe("TestApp");
backupDb.close();
sqlite.close();
});
});
describe("migration on imported DB", () => {
it("should successfully migrate an imported database", () => {
const sourcePath = path.join(tmpDir, "source.sqlite");
const { sqlite: sourceDb } = createTestDb(sourcePath);
sourceDb.exec(`
INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status")
VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
`);
sourceDb.close();
const importPath = path.join(tmpDir, "import.sqlite");
fs.copyFileSync(sourcePath, importPath);
const importDb = new Database(importPath);
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
expect(board).toBeDefined();
expect(board!.name).toBe("TestBoard");
importDb.close();
});
});
describe("encryption key validation", () => {
it("should reject invalid hex keys", () => {
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
for (const key of invalidKeys) {
expect(hexKeyRegex.test(key)).toBe(false);
}
});
it("should accept valid 64-char hex keys", () => {
const validKey = generateHexKey();
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
expect(hexKeyRegex.test(validKey)).toBe(true);
});
});
});
@@ -0,0 +1,114 @@
"use client";
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { BackupAnalysis } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
interface BackupPreviewPanelProps {
analysis: BackupAnalysis;
}
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.preview");
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
const pendingCount = analysis.migrations.pending.length;
const hasPendingMigrations = pendingCount > 0;
return (
<Stack gap="md">
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Group gap="sm">
<ThemeIcon variant="light" color="blue" size="lg" radius="xl">
<IconDatabase size={18} />
</ThemeIcon>
<div>
<Title order={5}>{t("title")}</Title>
<Text size="xs" c="dimmed">
{t("exported", { date: exportDate })}
</Text>
</div>
<Badge variant="light" color="gray" ml="auto">
Homarr {analysis.metadata.homarrVersion}
</Badge>
</Group>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("entity")}</Table.Th>
<Table.Th ta="right">{t("count")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{PREVIEW_TABLE_KEYS.map((key) => (
<Table.Tr key={key}>
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
<Table.Td ta="right">
<Group gap={4} justify="flex-end">
{(analysis.counts[key] ?? 0) > 0 && (
<IconCheck size={14} color="var(--mantine-color-green-6)" />
)}
<Text size="sm" ff="monospace">{analysis.counts[key] ?? 0}</Text>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{analysis.boardNames.length > 0 && (
<Group gap="xs" wrap="wrap">
<Text size="sm" fw={500}>{t("boards")}:</Text>
{analysis.boardNames.map((name) => (
<Badge key={name} variant="light" size="sm">{name}</Badge>
))}
</Group>
)}
</Stack>
</Paper>
{hasPendingMigrations && (
<Paper p="md" radius="md" withBorder bd="1px solid var(--mantine-color-orange-8)">
<Group gap="sm">
<ThemeIcon variant="light" color="orange" size="lg" radius="xl">
<IconGitBranch size={18} />
</ThemeIcon>
<div>
<Text size="sm" fw={600}>
{pendingCount === 1
? t("migrationsApplied", { count: String(pendingCount) })
: t("migrationsAppliedPlural", { count: String(pendingCount) })}
</Text>
<Text size="xs" c="dimmed">
{pendingCount === 1
? t("migrationsOlderVersion", { count: String(pendingCount) })
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
</Text>
</div>
<Badge variant="light" color="orange" ml="auto">
{t("migrationsPending", { count: String(pendingCount) })}
</Badge>
</Group>
</Paper>
)}
{!hasPendingMigrations && (
<Paper p="md" radius="md" withBorder>
<Group gap="sm">
<ThemeIcon variant="light" color="green" size="lg" radius="xl">
<IconGitBranch size={18} />
</ThemeIcon>
<Text size="sm" fw={500} c="dimmed">
{t("migrationsUpToDate", { count: String(analysis.migrations.total) })}
</Text>
</Group>
</Paper>
)}
</Stack>
);
};
@@ -0,0 +1,91 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react";
const CONFIRMATION_PHRASE = "I understand";
interface DangerousActionConfirmationProps {
title: string;
warningTitle: string;
warningBody: string;
typePrompt: string;
submitLabel: string;
submitIcon: ReactNode;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
disabled?: boolean;
color?: string;
}
export const DangerousActionConfirmation = ({
title,
warningTitle,
warningBody,
typePrompt,
submitLabel,
submitIcon,
cancelLabel,
onConfirm,
onCancel,
disabled,
color = "red",
}: DangerousActionConfirmationProps) => {
const [value, setValue] = useState("");
const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase();
return (
<Paper p="lg" radius="md" withBorder bd={`1px solid var(--mantine-color-${color}-9)`}>
<Stack gap="md">
<Group gap="sm">
<IconShieldExclamation size={24} color={`var(--mantine-color-${color}-6)`} />
<Text size="lg" fw={700} c={color}>
{title}
</Text>
</Group>
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
<Stack gap={4}>
<Text size="sm" fw={600}>{warningTitle}</Text>
<Text size="sm">{warningBody}</Text>
</Stack>
</Alert>
<Box>
<Text size="sm" fw={500} mb={4}>
{typePrompt}
</Text>
<TextInput
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder={CONFIRMATION_PHRASE}
disabled={disabled}
styles={{
input: {
fontFamily: "var(--mantine-font-family-monospace)",
borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined,
},
}}
/>
</Box>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
{cancelLabel}
</Button>
<Button
color={color}
leftSection={submitIcon}
onClick={onConfirm}
disabled={!isMatch || disabled}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Paper>
);
};
@@ -0,0 +1,227 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
import type { RestoreStep } from "./types";
import { BackupPreviewPanel } from "./backup-preview-panel";
import { MigrationProgressPanel } from "./migration-progress-panel";
import { RestoreConfirmation } from "./restore-confirmation";
import { RestoreProgressPanel } from "./restore-progress-panel";
import { useBackupAnalysis } from "./use-backup-analysis";
interface DatabaseRestoreFlowProps {
variant?: "card" | "standalone";
onRestoreComplete?: () => void;
}
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
const t = useScopedI18n("management.page.tool.backup.restore");
const [file, setFile] = useState<FileWithPath | null>(null);
const [step, setStep] = useState<RestoreStep>("upload");
const [importError, setImportError] = useState<string | null>(null);
const apiDoneRef = useRef(false);
const animDoneRef = useRef(false);
const apiErrorRef = useRef<string | null>(null);
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
const handleFileDrop = useCallback(
(files: FileWithPath[]) => {
const droppedFile = files[0];
if (!droppedFile) return;
setFile(droppedFile);
setStep("preview");
setImportError(null);
void analyzeFile(droppedFile);
},
[analyzeFile],
);
const handleClear = useCallback(() => {
setFile(null);
setStep("upload");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
reset();
}, [reset]);
const tryFinalize = useCallback(() => {
if (!apiDoneRef.current) return;
if (apiErrorRef.current) {
setImportError(apiErrorRef.current);
setStep("error");
return;
}
if (!animDoneRef.current) return;
onRestoreComplete?.();
window.location.reload();
}, [onRestoreComplete]);
const handleConfirm = useCallback(async () => {
if (!file) return;
setStep("restoring");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
if (!response.ok) {
try {
const data = await response.json();
apiErrorRef.current = data.error ?? t("failed.title");
} catch {
apiErrorRef.current = `Server returned ${response.status}`;
}
}
} catch {
apiErrorRef.current = t("failed.title");
} finally {
apiDoneRef.current = true;
tryFinalize();
}
}, [file, t, tryFinalize]);
const handleAnimationComplete = useCallback(() => {
animDoneRef.current = true;
tryFinalize();
}, [tryFinalize]);
if (step === "upload") {
return (
<Dropzone
onDrop={handleFileDrop}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
multiple={false}
maxSize={1024 * 1024 * 256}
radius="md"
>
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{t("dropzone.title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{t("dropzone.description")}
</Text>
</div>
</Group>
</Dropzone>
);
}
if (step === "preview") {
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<IconFileZip size={20} />
<div>
<Text size="sm" fw={500}>{file?.name}</Text>
<Text size="xs" c="dimmed">{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}</Text>
</div>
</Group>
<Button variant="subtle" size="xs" onClick={handleClear}>
{t("changeFile")}
</Button>
</Group>
{loading && migrationProgress && (
<MigrationProgressPanel progress={migrationProgress} />
)}
{loading && !migrationProgress && (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
</Group>
)}
{analysisError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("analysisError")}>
{analysisError}
</Alert>
)}
{analysis && (
<>
<BackupPreviewPanel analysis={analysis} />
<Group justify="flex-end">
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => setStep("confirm")}
>
{t("continueToRestore")}
</Button>
</Group>
</>
)}
</Stack>
);
}
if (step === "confirm") {
return (
<Stack gap="md">
{analysis && <BackupPreviewPanel analysis={analysis} />}
<RestoreConfirmation
onConfirm={handleConfirm}
onCancel={() => setStep("preview")}
/>
</Stack>
);
}
if (step === "restoring") {
return (
<RestoreProgressPanel
active
migrations={analysis?.migrations.pending ?? []}
onComplete={handleAnimationComplete}
/>
);
}
if (step === "error") {
return (
<Stack gap="md">
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("failed.title")} radius="md">
<Text size="sm">{importError}</Text>
</Alert>
<Group>
<Button variant="subtle" onClick={handleClear}>
{t("tryAgain")}
</Button>
</Group>
</Stack>
);
}
return null;
};
@@ -0,0 +1,3 @@
export { DatabaseRestoreFlow } from "./database-restore-flow";
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
export type { BackupAnalysis, RestoreStep } from "./types";
@@ -0,0 +1,60 @@
"use client";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationProgress } from "./use-backup-analysis";
interface MigrationProgressPanelProps {
progress: MigrationProgress;
}
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.migration");
return (
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
<IconDatabase size={12} />
</ThemeIcon>
<Text size="sm" fw={600}>
{t("title", { current: String(progress.current), total: String(progress.total) })}
</Text>
</Group>
<Box pl={4}>
<Timeline active={progress.current - 1} bulletSize={20} lineWidth={2} color="blue">
{Array.from({ length: progress.total }, (_, i) => {
const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done");
const isActive = i === progress.current - 1 && progress.phase === "applying";
return (
<Timeline.Item
key={i}
bullet={
isDone ? (
<IconCheck size={12} />
) : isActive ? (
<Loader size={10} color="white" />
) : (
<IconLoader size={12} />
)
}
title={
<Text size="xs" ff="monospace" c={isDone ? "dimmed" : isActive ? "white" : "dimmed"}>
{i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })}
</Text>
}
/>
);
})}
</Timeline>
</Box>
</Stack>
</Paper>
);
};
@@ -0,0 +1,32 @@
"use client";
import { IconDatabaseImport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
interface RestoreConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
disabled?: boolean;
}
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
return (
<DangerousActionConfirmation
title={t("title")}
warningTitle={t("warningTitle")}
warningBody={t("warningBody")}
typePrompt={t("typePrompt", { phrase: "I understand" })}
submitLabel={t("submit")}
submitIcon={<IconDatabaseImport size={16} />}
cancelLabel={t("cancel")}
onConfirm={onConfirm}
onCancel={onCancel}
disabled={disabled}
/>
);
};
@@ -0,0 +1,159 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import {
IconCheck,
IconDatabaseImport,
IconFileZip,
IconKey,
IconLoader,
IconRefresh,
IconTransform,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationFile } from "./types";
const BASE_PHASE_DURATIONS: Record<string, number> = {
extracting: 1200,
encrypting: 1500,
swapping: 800,
restarting: 10000,
};
const MIGRATION_STEP_DURATION = 100;
interface TimelineStep {
key: string;
label: string;
icon: Icon;
duration: number;
}
interface RestoreProgressPanelProps {
active: boolean;
migrations: MigrationFile[];
onComplete?: () => void;
}
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.progress");
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
const steps = useMemo((): TimelineStep[] => {
const result: TimelineStep[] = [
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
];
if (migrations.length > 0) {
for (const migration of migrations) {
result.push({
key: `migration-${migration.idx}`,
label: migration.tag,
icon: IconDatabaseImport,
duration: MIGRATION_STEP_DURATION,
});
}
} else {
result.push({
key: "migrating",
label: t("migrating"),
icon: IconDatabaseImport,
duration: 300,
});
}
result.push(
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! },
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! },
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! },
);
return result;
}, [migrations, t]);
useEffect(() => {
if (!active) return;
let cancelled = false;
const run = async () => {
for (let i = 0; i < steps.length; i++) {
if (cancelled) return;
setCurrentStepIdx(i);
await new Promise((r) => setTimeout(r, steps[i]!.duration));
}
if (!cancelled) {
onComplete?.();
}
};
void run();
return () => { cancelled = true; };
}, [active, onComplete, steps]);
if (!active && currentStepIdx === -1) return null;
return (
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
<Stack gap="md">
<Group gap="xs">
<Loader size="sm" color="blue" />
<Text size="sm" fw={600}>
{t("title")}
</Text>
</Group>
<Box pl={4}>
<Timeline
active={currentStepIdx}
bulletSize={24}
lineWidth={2}
color="blue"
>
{steps.map((step, i) => {
const isDone = i < currentStepIdx;
const isActive = i === currentStepIdx;
const isMigration = step.key.startsWith("migration-");
return (
<Timeline.Item
key={step.key}
bullet={
isDone ? (
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
<IconCheck size={14} />
</ThemeIcon>
) : isActive ? (
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
<Loader size={12} color="white" />
</ThemeIcon>
) : (
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
<IconLoader size={14} />
</ThemeIcon>
)
}
>
<Group gap="xs" mt={-4}>
<step.icon size={16} color={isDone ? "var(--mantine-color-green-6)" : isActive ? "var(--mantine-color-blue-5)" : "var(--mantine-color-dimmed)"} />
<Text
size={isMigration ? "xs" : "sm"}
fw={isActive ? 600 : 400}
ff={isMigration ? "monospace" : undefined}
c={isDone ? "green" : isActive ? "white" : "dimmed"}
>
{step.label}
</Text>
</Group>
</Timeline.Item>
);
})}
</Timeline>
</Box>
</Stack>
</Paper>
);
};
@@ -0,0 +1,30 @@
export interface BackupMetadata {
homarrVersion: string;
exportedAt: string;
dbDialect: string;
encryptionKey?: string;
}
export interface MigrationFile {
idx: number;
tag: string;
sql: string;
when: number;
}
export interface MigrationStatus {
applied: number;
pending: MigrationFile[];
total: number;
}
export interface BackupAnalysis {
metadata: BackupMetadata;
counts: Record<string, number>;
boardNames: string[];
migrations: MigrationStatus;
}
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
@@ -0,0 +1,187 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const getAppliedMigrationCount = (db: any): number => {
try {
const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`);
return (result[0]?.values[0]?.[0] as number) ?? 0;
} catch {
return 0;
}
};
const fetchMigrations = async (): Promise<MigrationFile[]> => {
const res = await fetch("/api/backup/migrations");
if (!res.ok) throw new Error("Failed to fetch migration files from server");
const data = await res.json();
return data.migrations ?? [];
};
const countEntities = (db: any): Record<string, number> => {
const counts: Record<string, number> = {};
for (const key of PREVIEW_TABLE_KEYS) {
try {
const result = db.exec(`SELECT COUNT(*) FROM "${key}"`);
counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0;
} catch {
counts[key] = 0;
}
}
return counts;
};
const getBoardNames = (db: any): string[] => {
try {
const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"');
return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? [];
} catch {
return [];
}
};
export interface MigrationProgress {
current: number;
total: number;
tag: string;
phase: "pending" | "applying" | "done" | "error";
}
export const useBackupAnalysis = () => {
const t = useScopedI18n("management.page.tool.backup.restore");
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress | null>(null);
const dbRef = useRef<any>(null);
const analyzeFile = useCallback(async (file: File) => {
setLoading(true);
setError(null);
setAnalysis(null);
setMigrationProgress(null);
try {
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
import("jszip").then((m) => m.default),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import("sql.js").then((m: any) => m.default),
fetchMigrations(),
fetch("/api/backup/sql-wasm").then((r) => {
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
return r.arrayBuffer();
}),
]);
const arrayBuffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const metadataFile = zip.file("metadata.json");
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
const dbFile = zip.file("db.sqlite");
if (!dbFile) throw new Error(t("invalidMissingDb"));
const metadata = JSON.parse(await metadataFile.async("string"));
const dbBuffer = await dbFile.async("uint8array");
const SQL = await initSqlJs({ wasmBinary });
const db = new SQL.Database(dbBuffer);
dbRef.current = db;
try {
const appliedCount = getAppliedMigrationCount(db);
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
const migrations: MigrationStatus = {
applied: appliedCount,
pending: pendingMigrations,
total: allMigrations.length,
};
if (pendingMigrations.length > 0) {
for (let i = 0; i < pendingMigrations.length; i++) {
const migration = pendingMigrations[i]!;
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "applying",
});
await delay(100);
try {
const statements = migration.sql
.split("--> statement-breakpoint")
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements) {
db.run(stmt);
}
db.run(
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
[migration.tag, Date.now()],
);
} catch (migrationErr) {
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "error",
});
throw new Error(
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
);
}
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "done",
});
await delay(50);
}
}
const counts = countEntities(db);
const boardNames = getBoardNames(db);
setMigrationProgress(null);
setAnalysis({ metadata, counts, boardNames, migrations });
} finally {
db.close();
dbRef.current = null;
}
} catch (err) {
setError(err instanceof Error ? err.message : t("analyzeError"));
} finally {
setLoading(false);
}
}, [t]);
const reset = useCallback(() => {
if (dbRef.current) {
try { dbRef.current.close(); } catch { /* already closed */ }
dbRef.current = null;
}
setAnalysis(null);
setError(null);
setMigrationProgress(null);
}, []);
return { analysis, loading, error, migrationProgress, analyzeFile, reset };
};
@@ -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>
);
};
@@ -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,
});
@@ -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,
});
+1
View File
@@ -2,3 +2,4 @@ declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";
declare module "sql.js";
+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 });
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 32M;
client_max_body_size 256M;
}
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.61.1",
"version": "1.64.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -78,7 +78,7 @@
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"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",
@@ -88,13 +88,13 @@
"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.7",
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
"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({
+24 -22
View File
@@ -613,7 +613,7 @@ export const boardRouter = createTRPCRouter({
boardId: board.id,
});
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
const layoutToClone =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -1215,7 +1215,7 @@ export const boardRouter = createTRPCRouter({
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
@@ -1223,7 +1223,7 @@ export const boardRouter = createTRPCRouter({
user,
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupBoardPermission
@@ -1234,7 +1234,7 @@ export const boardRouter = createTRPCRouter({
},
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
@@ -1586,7 +1586,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
...otherBoardProperties,
layouts: layouts
.map(({ boardId: _, ...layout }) => layout)
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
sections: sections.map(({ collapseStates, ...section }) =>
parseSection({
...section,
@@ -1604,22 +1604,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
collapsed: collapseStates.at(0)?.collapsed ?? false,
}),
),
items: items.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
),
items: items
.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
)
.filter((item) => item !== null),
};
};
@@ -1635,7 +1637,7 @@ const parseItem = (item: unknown) => {
const result = outputItemSchema.safeParse(item);
if (!result.success) {
throw new Error(result.error.message);
return null;
}
return result.data;
};
@@ -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,
},
};
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff. Show more