Compare commits

..
Author SHA1 Message Date
ajnart cebb5dc2bb fix(docs): restore dataflow backgrounds 2026-08-13 22:35:43 +02:00
ajnart 3318ddc8c3 docs: add dataflow preview 2026-08-13 22:24:24 +02:00
ajnart e656731729 fix(docs): cycle integration flow icons 2026-08-13 22:21:40 +02:00
ajnart 2257fe2a08 fix(docs): simplify integration nodes 2026-08-13 22:16:54 +02:00
ajnart 673589f6c8 fix(docs): restore homepage integration flow 2026-08-13 22:13:08 +02:00
ajnart c642f9a8d3 fix: terminate invalid provider streams 2026-08-12 13:35:40 +02:00
ajnart 83968be07e feat: complete the Homarr provider endpoint 2026-08-12 13:27:04 +02:00
ajnart 019bd11017 fix: clarify provider request accounting 2026-08-12 12:58:17 +02:00
ajnart c16e00f94c fix: address provider review feedback 2026-08-12 12:51:15 +02:00
ajnart bffa3e723c fix: harden the Homarr provider 2026-08-12 12:33:04 +02:00
ajnart 0774b02878 Harden Homarr provider limits 2026-08-11 22:53:49 +02:00
ajnart 47c349bcd4 docs: improve workshop and homepage 2026-08-11 22:38:43 +02:00
ajnart 874432becf Handle provider activity load failures 2026-08-11 22:16:51 +02:00
ajnart 958cc68cba Localize provider errors 2026-08-11 22:04:38 +02:00
ajnart abaf845e94 Address CodeRabbit provider feedback 2026-08-11 22:03:21 +02:00
ajnart ea9075bde5 Address Homarr provider review feedback 2026-08-11 21:50:40 +02:00
ajnart 4ebaea29fd Harden Homarr provider integration 2026-08-11 19:47:11 +02:00
ajnart 165201632d Complete Homarr provider integration 2026-08-11 19:19:17 +02:00
ajnart bc59355eab Add Homarr provider proxy foundation 2026-08-11 19:02:34 +02:00
Thomas Camlong 9143c15454 Merge branch 'feat/repair-stack-mobile-20260811' into feat/repair-stack-assistant-20260811 2026-08-11 17:03:22 +02:00
Thomas Camlong 3428e3682b Merge branch 'feat/repair-stack-widgets-20260811' into feat/repair-stack-mobile-20260811 2026-08-11 17:03:22 +02:00
Thomas Camlong 5b8f9187d5 Merge branch 'feat/repair-stack-dnd-20260811' into feat/repair-stack-widgets-20260811 2026-08-11 17:03:22 +02:00
Thomas Camlong a61e9d5efc Merge branch 'feat/repair-stack-v2-20260811' into feat/repair-stack-dnd-20260811 2026-08-11 17:03:21 +02:00
Thomas Camlong bf15e64b63 style: format v2 workshop files 2026-08-11 17:03:09 +02:00
Thomas Camlong e2dd68fdbc Merge branch 'feat/auto-mobile-boards' into feat/homarr-assistant 2026-08-11 16:59:12 +02:00
Thomas Camlong 16953fc019 Merge branch 'feat/widget-modernization' into feat/auto-mobile-boards 2026-08-11 16:57:29 +02:00
Thomas Camlong 545a5c1d96 Merge branch 'feat/dnd-kit-grid' into feat/widget-modernization 2026-08-11 16:57:05 +02:00
Thomas Camlong e55e709b72 Merge branch 'v2' into feat/dnd-kit-grid 2026-08-11 16:55:34 +02:00
Thomas Camlong 117eabd30b Merge branch 'dev' into v2 2026-08-11 16:55:16 +02:00
Thomas 38af22aba0 test: cover public custom widgets for guests (#6558) 2026-08-11 15:25:22 +02:00
Thomas Camlong cba20bf665 fix: simplify grid interaction previews 2026-08-11 15:09:53 +02:00
Thomas 827ba7cda4 feat: rewrite image picker (#6556)
* feat: rewrite image picker

* style: auto-fix lint and format

* fix: address image picker review

* fix: preserve unicode icon searches

* test: stabilize custom widget login

* fix: suggest image searches only on create
2026-08-11 14:51:31 +02:00
Thomas Camlong 513549784f Merge branch 'feat/auto-mobile-boards' into feat/homarr-assistant 2026-08-11 11:36:06 +02:00
Thomas Camlong a96105fb09 Merge branch 'feat/widget-modernization' into feat/auto-mobile-boards 2026-08-11 11:35:40 +02:00
Thomas Camlong f28d61faa1 Merge branch 'feat/dnd-kit-grid' into feat/widget-modernization 2026-08-11 11:35:14 +02:00
Thomas Camlong dc66493caa perf: isolate board grid previews 2026-08-11 11:27:58 +02:00
Thomas Camlong b7934d7e87 Merge branch 'feat/auto-mobile-boards' into feat/homarr-assistant 2026-08-11 11:06:18 +02:00
Thomas Camlong 4b5166dfed Merge branch 'feat/widget-modernization' into feat/auto-mobile-boards 2026-08-11 11:05:52 +02:00
Thomas Camlong afe17eefc8 Merge branch 'feat/dnd-kit-grid' into feat/widget-modernization 2026-08-11 11:05:26 +02:00
Thomas Camlong a7ce15eaea Merge branch 'v2' into feat/dnd-kit-grid 2026-08-11 11:05:01 +02:00
Thomas Camlong 44c22ccad3 ci: retry flaky container-backed unit tests in fast gate
Integration tests (nzbget, nextcloud, pi-hole, etc.) start Docker
containers and intermittently time out under shared-runner load, which
fails the fast gate and skips the container job that publishes the PR
preview image. Retry failing unit tests twice so transient container
startup failures do not block the image publish.
2026-08-11 11:03:33 +02:00
Thomas Camlong eae17877a1 ci: make e2e tests resilient to CI resource contention
E2E specs spin up Docker containers in parallel and intermittently time
out waiting for UI elements under shared-runner load. Limit parallel
workers to 2 and retry flaky failures twice so the container job can
complete and publish the PR preview image.
2026-08-11 11:03:12 +02:00
ajnart 6435955a51 test: wait for Home Assistant API readiness 2026-08-11 11:02:43 +02:00
Thomas Camlong 09bc5a16f4 test: align assistant credential flow 2026-08-10 20:43:03 +02:00
Thomas Camlong 223ef33bfd Merge branch 'feat/auto-mobile-boards' into feat/homarr-assistant 2026-08-10 20:13:05 +02:00
Thomas Camlong d8f71f46be Merge branch 'feat/widget-modernization' into feat/auto-mobile-boards 2026-08-10 20:13:00 +02:00
Thomas Camlong bc687b90ad Merge branch 'feat/dnd-kit-grid' into feat/widget-modernization 2026-08-10 20:13:00 +02:00
Thomas Camlong 3f9e83ab97 Merge branch 'feat/widget-modernization' into feat/auto-mobile-boards 2026-08-10 20:12:36 +02:00
Thomas Camlong 8fff3e413e Merge branch 'feat/dnd-kit-grid' into feat/widget-modernization 2026-08-10 20:12:33 +02:00
Thomas Camlong c2b7e43d7f Merge branch 'feat/dnd-kit-grid' into feat/widget-modernization 2026-08-10 20:12:05 +02:00
Thomas Camlong 16dd251276 fix: disable app context menu 2026-08-10 20:10:22 +02:00
Thomas Camlong b830479f09 Merge branch 'v2' into feat/dnd-kit-grid 2026-08-10 20:08:16 +02:00
Thomas Camlong 7a45b0d119 feat(workshop): add social previews 2026-08-10 20:07:51 +02:00
ajnart 8dd6cbbfdb fix assistant focus and settings 2026-08-10 19:49:33 +02:00
Thomas Camlong b7ddc028a5 Merge commit '1f17c77c9' into feat/homarr-assistant 2026-08-10 19:48:29 +02:00
Thomas Camlong 1f17c77c97 Merge commit '7d3625ecd' into feat/auto-mobile-boards 2026-08-10 19:48:20 +02:00
Thomas Camlong 7d3625ecdc Merge commit '6a08ae395' into feat/widget-modernization 2026-08-10 19:48:12 +02:00
Thomas Camlong 6a08ae3951 Merge commit '8b49982c3' into feat/dnd-kit-grid 2026-08-10 19:48:03 +02:00
Thomas Camlong 8b49982c3a fix: persist Workshop PocketBase data 2026-08-10 19:47:29 +02:00
Thomas Camlong 3c8fe54618 Merge commit '05b4f4dc5' into HEAD 2026-08-10 19:17:26 +02:00
Thomas Camlong 05b4f4dc52 Merge commit 'cae663407' into HEAD 2026-08-10 19:17:14 +02:00
Thomas Camlong cae6634074 Merge commit 'e5899521b' into HEAD 2026-08-10 19:16:56 +02:00
Thomas Camlong e5899521b9 Merge commit 'ad2fa1605' into HEAD 2026-08-10 19:16:39 +02:00
Thomas Camlong ad2fa1605e fix: defer Spotlight without loading feedback 2026-08-10 19:16:16 +02:00
Thomas Camlong b4bbaa2e12 Merge commit '55bc3353b' into HEAD 2026-08-10 19:14:07 +02:00
Thomas Camlong 55bc3353b6 Merge commit 'a60f15d36' into HEAD
# Conflicts:
#	apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
2026-08-10 19:13:51 +02:00
Thomas Camlong a60f15d366 Merge commit '9a2fe1324' into HEAD 2026-08-10 19:13:13 +02:00
Thomas Camlong 9a2fe13242 Merge remote-tracking branch 'origin/v2' into HEAD 2026-08-10 19:12:48 +02:00
Thomas Camlong 106fca2a61 fix: simplify assistant key management 2026-08-10 19:09:04 +02:00
Thomas Camlong 920631373c fix: refine responsive layout settings 2026-08-10 19:09:04 +02:00
ajnart 533003e49b test custom widget assistant authoring 2026-08-10 19:02:55 +02:00
Thomas Camlong a6061348c3 fix: polish settings and command menu 2026-08-10 19:00:43 +02:00
Thomas Camlong 8557f2107c Merge commit 'f81d66a16' into feat/homarr-assistant 2026-08-10 18:48:57 +02:00
Thomas Camlong f81d66a16f Merge commit 'd04eec39b' into feat/auto-mobile-boards 2026-08-10 18:48:37 +02:00
Thomas Camlong d04eec39b6 Merge commit '22e955529' into feat/widget-modernization 2026-08-10 18:48:22 +02:00
Thomas Camlong 22e955529b Merge commit 'e524515ca' into feat/dnd-kit-grid
# Conflicts:
#	apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
2026-08-10 18:48:05 +02:00
Thomas Camlong e524515ca5 fix(workshop): streamline import review 2026-08-10 18:47:23 +02:00
Thomas Camlong 920c1a7a4c Merge commit '3fbd7ef04' into feat/propagate-workshop-install-assistant 2026-08-10 18:21:51 +02:00
Thomas Camlong 3fbd7ef04d Merge commit '333bab6d2' into feat/propagate-workshop-install-mobile 2026-08-10 18:21:31 +02:00
Thomas Camlong 333bab6d2a Merge commit 'ba582d04c' into feat/propagate-workshop-install-widgets 2026-08-10 18:21:18 +02:00
Thomas Camlong ba582d04ca Merge commit 'cf3c4b415' into feat/propagate-workshop-install-dnd 2026-08-10 18:21:00 +02:00
Thomas Camlong cf3c4b4152 fix(workshop): stack widget install flow 2026-08-10 18:19:31 +02:00
Thomas Camlong 3ea931d456 Merge commit '46b782847' into feat/propagate-workshop-admin-assistant
# Conflicts:
#	apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx
2026-08-10 17:40:40 +02:00
Thomas Camlong 46b782847c Merge commit 'cbb383959' into feat/propagate-workshop-admin-mobile 2026-08-10 17:38:05 +02:00
Thomas Camlong cbb383959e Merge commit '3ac76491c' into feat/propagate-workshop-admin-widgets 2026-08-10 17:37:51 +02:00
Thomas Camlong 3ac76491c6 Merge commit '378bc0cd2' into feat/propagate-workshop-admin-dnd 2026-08-10 17:37:29 +02:00
Thomas Camlong 378bc0cd28 fix(workshop): expose moderation 2026-08-10 17:37:13 +02:00
Thomas Camlong be93fb330a fix: simplify custom widget preview controls 2026-08-10 17:22:40 +02:00
ajnart 6d72f4ab83 fix assistant custom widget creation 2026-08-10 17:14:15 +02:00
Thomas Camlong 5125b46f03 Merge commit 'eebe3ab0f' into feat/homarr-assistant 2026-08-10 16:25:54 +02:00
Thomas Camlong eebe3ab0fd Merge commit '001bb9dc4' into feat/auto-mobile-boards 2026-08-10 16:25:36 +02:00
Thomas Camlong 001bb9dc4e Merge commit '4a96078bf' into feat/widget-modernization 2026-08-10 16:25:23 +02:00
Thomas Camlong 4a96078bf7 Merge commit '893fcd6ed' into feat/dnd-kit-grid 2026-08-10 16:25:10 +02:00
Thomas Camlong 893fcd6ed1 fix(workshop): use PocketBase name 2026-08-10 16:24:52 +02:00
Thomas Camlong 5387b3f92a Merge mobile Workshop consumer fix into assistant 2026-08-10 15:33:44 +02:00
Thomas Camlong aa8d68d224 Merge widget Workshop consumer fix into mobile 2026-08-10 15:33:30 +02:00
Thomas Camlong 2f12fd6fb3 Merge DnD Workshop consumer fix into widgets 2026-08-10 15:33:17 +02:00
Thomas Camlong 581e5105cc Merge v2 Workshop consumer fix into DnD 2026-08-10 15:33:03 +02:00
Thomas Camlong 0422675bc1 fix(workshop): use GitHub handles in Homarr 2026-08-10 15:32:32 +02:00
ajnart c7fd5eceae fix assistant tool result streaming 2026-08-10 15:24:34 +02:00
Thomas Camlong 414f5dfe53 Merge mobile docs typecheck fix into assistant 2026-08-10 15:22:55 +02:00
Thomas Camlong 34918352f1 Merge widget docs typecheck fix into mobile 2026-08-10 15:22:43 +02:00
Thomas Camlong 1450162d31 Merge DnD docs typecheck fix into widgets 2026-08-10 15:22:29 +02:00
Thomas Camlong 6e1b6d9ff3 Merge v2 docs typecheck fix into DnD 2026-08-10 15:22:17 +02:00
Thomas Camlong d610206896 fix(docs): exclude generated build from typecheck 2026-08-10 15:21:47 +02:00
Thomas Camlong a6dc90601e Merge mobile Workshop identity fix into assistant 2026-08-10 15:15:57 +02:00
Thomas Camlong efd3dae0db Merge widget Workshop identity fix into mobile 2026-08-10 15:15:36 +02:00
Thomas Camlong 2d594c22cc Merge DnD Workshop identity fix into widgets 2026-08-10 15:15:17 +02:00
Thomas Camlong 78d22c2514 Merge v2 Workshop identity fix into DnD 2026-08-10 15:14:56 +02:00
Thomas Camlong 64403079d8 fix(workshop): simplify GitHub identities 2026-08-10 15:14:32 +02:00
ajnart dc40ec2715 test assistant v7 request payload 2026-08-10 14:59:57 +02:00
ajnart b03849604e test assistant AI SDK v7 transport 2026-08-10 14:57:47 +02:00
ajnart 82bc4e21a9 chore upgrade assistant to AI SDK v7 2026-08-10 14:48:09 +02:00
Thomas Camlong 2495b4c4a2 Merge latest mobile boards into assistant 2026-08-10 14:24:50 +02:00
Thomas Camlong e5920b2f56 Merge latest widget modernization into mobile boards 2026-08-10 14:24:33 +02:00
Thomas Camlong 6ce08508fa Merge latest dnd-kit grid into widget modernization 2026-08-10 14:24:13 +02:00
Thomas Camlong 76a2aebb4a Merge latest v2 into dnd-kit grid 2026-08-10 14:23:52 +02:00
Thomas Camlong 015e0cbf63 Merge dev into v2 2026-08-10 14:23:19 +02:00
ajnart 305c9af2da fix assistant exports and tool calls 2026-08-10 14:09:44 +02:00
ajnart f7eed7ce8a refine assistant window controls 2026-08-10 14:09:44 +02:00
Thomas Camlong 6baac9298f Merge commit '9054dcad7' into feat/homarr-assistant 2026-08-10 13:22:00 +02:00
Thomas Camlong 9054dcad7a Merge commit 'f9a457982' into feat/auto-mobile-boards 2026-08-10 13:21:29 +02:00
Thomas Camlong f9a4579821 Merge commit '7cd56bdbd' into feat/widget-modernization 2026-08-10 13:20:49 +02:00
Thomas Camlong 7cd56bdbda perf: isolate grid interaction renders 2026-08-10 13:20:13 +02:00
Thomas Camlong 0f862de208 Merge feat/auto-mobile-boards into feat/homarr-assistant 2026-08-10 12:02:53 +02:00
Thomas Camlong 8ee8990f09 Merge feat/widget-modernization into feat/auto-mobile-boards 2026-08-10 12:02:35 +02:00
Thomas Camlong b6053a7b31 Merge feat/dnd-kit-grid into feat/widget-modernization 2026-08-10 12:02:06 +02:00
Thomas Camlong fe37673c95 fix: stabilize rail and resize interactions 2026-08-10 12:01:35 +02:00
ajnart 491b430b64 fix assistant dashboard placement 2026-08-10 11:58:32 +02:00
ajnart 12fe596a9e feat: add assistant window controls 2026-08-10 11:39:52 +02:00
ajnart 4118b6dd3b fix assistant tool continuation 2026-08-10 11:34:33 +02:00
Thomas Camlong ceec25af81 merge WUD OpenAPI schema 2026-08-10 10:54:58 +02:00
Thomas Camlong 6088061953 Merge commit 'b3e55eb20' into feat/schema-auto 2026-08-10 10:54:18 +02:00
Thomas Camlong b3e55eb20c Merge commit '1a349b9e9' into feat/schema-widget 2026-08-10 10:53:58 +02:00
Thomas Camlong 1a349b9e96 Merge commit '52266fc7f' into feat/schema-dnd 2026-08-10 10:53:46 +02:00
Thomas Camlong 52266fc7f6 docs: update WUD OpenAPI schema 2026-08-10 10:53:30 +02:00
Thomas Camlong 2aa8455cbf merge latest widget catalog updates 2026-08-10 10:46:00 +02:00
Thomas Camlong 3b1a979d4f Merge commit '2a4ed87c6' into feat/final-refresh-auto 2026-08-10 10:45:21 +02:00
Thomas Camlong 2a4ed87c6e Merge commit '58db9ddf6' into feat/final-refresh-widget 2026-08-10 10:45:03 +02:00
Thomas Camlong 58db9ddf65 Merge commit 'd53021a0f' into feat/final-refresh-dnd 2026-08-10 10:44:39 +02:00
Thomas Camlong d53021a0f3 Merge remote-tracking branch 'origin/dev' into feat/refresh-latest-dev-v2 2026-08-10 10:44:22 +02:00
Thomas Camlong b86cfaeb73 Merge commit '2372416fa' into feat/propagate-workshop-test-assistant 2026-08-10 10:17:02 +02:00
Thomas Camlong 2372416fac Merge commit '781c62a0f' into feat/propagate-workshop-test-auto 2026-08-10 10:16:47 +02:00
Thomas Camlong 781c62a0f5 Merge commit 'ae40fa032' into feat/propagate-workshop-test-widget 2026-08-10 10:16:32 +02:00
Thomas Camlong ae40fa0328 Merge commit 'c7e6ed894' into feat/propagate-workshop-test-dnd 2026-08-10 10:16:13 +02:00
Thomas Camlong c7e6ed8943 test: validate Workshop source locale 2026-08-10 10:15:58 +02:00
Thomas Camlong d1c5cfbcaf chore: leave en-GB to Crowdin 2026-08-10 10:09:30 +02:00
Thomas Camlong 69547495aa Merge commit '605439828' into feat/refresh-assistant-from-auto 2026-08-10 10:09:10 +02:00
Thomas Camlong 6054398285 Merge commit 'ff0b97dfb' into feat/refresh-auto-from-widget 2026-08-10 10:08:49 +02:00
Thomas Camlong ff0b97dfb4 chore: drop manual translations 2026-08-10 10:08:14 +02:00
Thomas Camlong 07422896aa Merge commit 'c1d93d4c7' into feat/refresh-widget-from-dnd 2026-08-10 10:07:53 +02:00
Thomas Camlong c1d93d4c7d chore: leave translations to Crowdin 2026-08-10 10:07:35 +02:00
Thomas Camlong 673fdf0027 Merge commit '5efe053c0' into feat/refresh-dnd-from-v2 2026-08-10 10:06:39 +02:00
Thomas Camlong 5efe053c0f merge dev into v2 2026-08-10 10:06:18 +02:00
Thomas Camlong 9344d4da18 fix: keep HTTP methods untranslated 2026-08-10 10:04:22 +02:00
Thomas Camlong 004eb94b95 Merge remote-tracking branch 'origin/feat/auto-mobile-boards' into feat/stack-agent-widget-fixes-assistant 2026-08-10 09:40:38 +02:00
Thomas Camlong 4822c2fa12 Merge remote-tracking branch 'origin/feat/widget-modernization' into feat/stack-agent-widget-fixes-mobile 2026-08-10 09:40:29 +02:00
Thomas Camlong 9c91ab0977 fix: rework media session details 2026-08-10 09:39:49 +02:00
Thomas Camlong 7e1edb844b fix: refine Beszel stats period controls 2026-08-10 09:39:49 +02:00
Thomas Camlong a5e0427fe7 Merge remote-tracking branch 'origin/feat/auto-mobile-boards' into feat/stack-immich-fit-assistant 2026-08-10 09:32:35 +02:00
Thomas Camlong 68070796a0 Merge remote-tracking branch 'origin/feat/widget-modernization' into feat/stack-immich-fit-mobile 2026-08-10 09:32:22 +02:00
Thomas Camlong b639ce8741 fix: show complete Immich photos in advanced mode 2026-08-10 09:31:54 +02:00
Thomas Camlong 34344ed57a Merge remote-tracking branch 'origin/feat/auto-mobile-boards' into feat/stack-downloads-tooltip-assistant 2026-08-10 09:27:24 +02:00
Thomas Camlong 58c0260e70 Merge remote-tracking branch 'origin/feat/widget-modernization' into feat/stack-downloads-tooltip-mobile 2026-08-10 09:27:17 +02:00
Thomas Camlong f5cde54705 fix: dismiss Downloads tooltips in advanced mode 2026-08-10 09:26:46 +02:00
Thomas Camlong bf4f93de4a Merge remote-tracking branch 'origin/feat/auto-mobile-boards' into feat/stack-beszel-assistant 2026-08-10 09:19:59 +02:00
Thomas Camlong ddff19c932 Merge remote-tracking branch 'origin/feat/widget-modernization' into feat/stack-beszel-mobile 2026-08-10 09:19:44 +02:00
Thomas Camlong 8307024abe fix: contain Beszel table status dots 2026-08-10 09:19:05 +02:00
Thomas Camlong 68125d8d30 Merge v2 CI repair through mobile boards 2026-08-09 22:28:01 +02:00
Thomas Camlong ff6a273cd1 Merge v2 CI repair through widget modernization 2026-08-09 22:27:57 +02:00
Thomas Camlong 7d560e0675 Merge v2 CI repair through dnd kit grid 2026-08-09 22:27:54 +02:00
Thomas Camlong aafd5f4c9c Merge v2 CI repair into dnd kit grid 2026-08-09 22:27:48 +02:00
Thomas Camlong 1b489cc2e0 fix: stabilize v2 CI 2026-08-09 22:27:28 +02:00
Thomas Camlong 955c232c20 Merge refreshed mobile boards into Homarr Assistant 2026-08-09 22:00:50 +02:00
Thomas Camlong fe71b18636 Merge refreshed widget modernization into mobile boards 2026-08-09 22:00:30 +02:00
Thomas Camlong a547e29607 Merge refreshed dnd kit grid into widget modernization 2026-08-09 22:00:05 +02:00
Thomas Camlong 8cbd53d362 Merge current v2 into dnd kit grid 2026-08-09 21:59:44 +02:00
Thomas Camlong 7cf27c3f39 feat: add Workshop staging deployment 2026-08-09 21:40:20 +02:00
Thomas Camlong 8a3f8d5c12 fix: allow Workshop runtime config writes 2026-08-09 21:21:01 +02:00
Thomas Camlong 8f75954ca9 fix: prevent horizontal rail auto-scroll 2026-08-09 21:17:41 +02:00
ajnart 19abd46414 feat assistant context and agent trace 2026-08-09 21:07:33 +02:00
Thomas Camlong 1028752cbc fix: restore Workshop runtime URLs 2026-08-09 20:11:13 +02:00
ajnart f495dc8be7 fix assistant reasoning mount lifecycle 2026-08-09 18:09:11 +02:00
ajnart a06d8b99d9 feat: improve assistant thinking states 2026-08-09 18:09:11 +02:00
ajnart 90066f8e37 fix: refine assistant controls 2026-08-09 18:09:11 +02:00
ajnart 112db000fd fix: adjust assistant response limits 2026-08-09 18:09:11 +02:00
ajnart 45c1f9e5a9 fix: stabilize custom widget authoring 2026-08-09 18:09:11 +02:00
ajnart edc7b4d55b fix: harden assistant authoring and UI 2026-08-09 18:09:11 +02:00
Thomas Camlong fa0e55cc93 fix: validate MCP v2 routes 2026-08-09 18:09:11 +02:00
Thomas Camlong d1042ce09b Merge custom option test fix into assistant 2026-08-09 18:09:11 +02:00
Thomas Camlong 3b82ee034d Merge custom option test fix into mobile boards 2026-08-09 18:09:07 +02:00
Thomas Camlong 2215fdacfb Merge custom option test fix into widget modernization 2026-08-09 18:09:07 +02:00
Thomas Camlong 7821377f59 Merge custom option test fix into dnd kit 2026-08-09 18:09:07 +02:00
Thomas Camlong 2c97eec1d0 Merge custom option test fix into mobile boards 2026-08-09 18:08:39 +02:00
Thomas Camlong b30f8bc6ea Merge custom option test fix into widget modernization 2026-08-09 18:06:57 +02:00
Thomas Camlong cd8a69fa01 Merge custom option test fix into dnd kit 2026-08-09 18:06:57 +02:00
Thomas Camlong b8bdbc2f3e fix: restore focus after preview exit 2026-08-09 18:06:26 +02:00
Thomas Camlong 889d732e03 test: preserve advanced preview hover ownership 2026-08-09 18:06:26 +02:00
Thomas Camlong 54bd8d6343 feat: improve advanced preview activation 2026-08-09 18:06:26 +02:00
Thomas Camlong 6f1c81bc04 fix: smooth advanced focus entrance 2026-08-09 18:06:26 +02:00
Thomas Camlong 00688d5c1a Remove Docker advanced focus 2026-08-09 18:06:26 +02:00
Thomas Camlong fc8b9178a3 Merge custom option test fix into widget modernization 2026-08-09 18:06:25 +02:00
Thomas Camlong fb7c7d35d9 Merge custom option test fix into dnd kit 2026-08-09 18:05:27 +02:00
Thomas Camlong 198fda7854 Revert "ci: use self-hosted runners for stack"
This reverts commit a1d80515b0e00240bb662180626a44bfb205656e.
2026-08-09 18:03:38 +02:00
Thomas Camlong 66a1d5b210 ci: use self-hosted runners for stack 2026-08-09 18:03:38 +02:00
Thomas Camlong 5e68aab970 fix: anchor dashboard rail labels 2026-08-09 18:03:38 +02:00
Thomas Camlong ca9d836fac Merge remote-tracking branch 'origin/feat/dnd-kit-grid' into HEAD 2026-08-09 18:03:38 +02:00
Thomas Camlong 82a50de403 fix: size dashboard rails to viewport 2026-08-09 18:03:37 +02:00
Thomas Camlong 41606cef86 fix: widen workshop CSS import 2026-08-09 18:03:37 +02:00
Thomas Camlong 7d9caf407c style: format board grid test 2026-08-09 18:03:37 +02:00
Thomas Camlong 59528ca1c3 test: allow dnd-kit DOM modifier import 2026-08-09 18:03:37 +02:00
Thomas Camlong 9373e3d69a fix: preserve narrow board interactions 2026-08-09 18:03:37 +02:00
Thomas Camlong 8acc134a36 fix: keep scaled board rails sticky 2026-08-09 18:03:37 +02:00
Thomas Camlong a4ff57ed85 test: assert clipped zoom overflow 2026-08-09 18:03:37 +02:00
Thomas Camlong c530190638 test: avoid physical zoom size assumption 2026-08-09 18:03:37 +02:00
Thomas Camlong d6b6b696b4 test: assert zoomed canvas fit invariant 2026-08-09 18:03:37 +02:00
Thomas Camlong 1473605445 style: format board grid coverage 2026-08-09 18:03:37 +02:00
Thomas Camlong 255b544a6c docs: align board geometry behavior 2026-08-09 18:03:37 +02:00
Thomas Camlong bcbde92c2e Merge sidebar stability fix into dnd kit 2026-08-09 18:03:37 +02:00
Thomas Camlong 63f82f1a35 Merge grid geometry fixes into dnd kit 2026-08-09 18:03:37 +02:00
Thomas Camlong 0a8e4fead0 fix: stabilize board rails 2026-08-09 18:03:37 +02:00
Thomas Camlong 03ecfef429 Merge layout compatibility fix into dnd kit 2026-08-09 18:03:37 +02:00
Thomas Camlong 7a97b7187b Fix board grid bounds and live growth 2026-08-09 18:03:37 +02:00
Thomas Camlong 899dfd7230 Merge custom option test fix into dnd kit 2026-08-09 18:03:37 +02:00
Thomas Camlong fc1dbd37f1 fix: accept legacy dynamic sections 2026-08-09 18:03:37 +02:00
Thomas Camlong b9c89c61a2 test: harden integration icon checks 2026-08-09 13:39:16 +02:00
Thomas Camlong b5dbeae864 test: fix custom option repair fixture 2026-08-09 09:21:55 +02:00
Thomas Camlong 5daa0570ac test: keep assistant widget compact 2026-08-09 09:20:51 +02:00
Thomas Camlong 9cd6bc90ed Merge widget option and advanced focus fixes into assistant 2026-08-09 09:20:18 +02:00
Thomas Camlong cfa104baf7 Merge advanced widget opt-in into mobile boards 2026-08-09 09:20:15 +02:00
Thomas Camlong f214c25e9c fix: require advanced widget focus opt-in 2026-08-09 09:19:54 +02:00
Thomas Camlong fcee8177af Merge custom widget option repair into widget modernization 2026-08-09 09:18:01 +02:00
Thomas Camlong 0d6f9337b4 Merge v2 custom widget option repair 2026-08-09 09:17:58 +02:00
Thomas Camlong d513c7fe46 fix: repair stale custom widget options 2026-08-09 09:17:32 +02:00
ajnart fa9d080eed feat: migrate MCP server to v2 2026-08-09 08:25:17 +02:00
Thomas Camlong 8fea2a2851 Merge migration renames into Homarr Assistant 2026-08-09 08:15:08 +02:00
Thomas Camlong 19a6e32d61 Merge migration renames into mobile boards 2026-08-09 08:14:40 +02:00
Thomas Camlong 0425c3630c Merge migration renames into widget modernization 2026-08-09 08:14:06 +02:00
Thomas Camlong 84db5ccfe7 Rename layout gutter migrations 2026-08-09 08:12:46 +02:00
Thomas Camlong d56e2b5d60 Rename Assistant migrations 2026-08-09 08:12:43 +02:00
Thomas Camlong ee7e090a1f Rename layout role migrations 2026-08-09 08:12:41 +02:00
Thomas Camlong 895ec9fad5 Merge branch 'feat/v2-stack-final-runtime-mobile' into feat/v2-stack-final-runtime-assistant 2026-08-09 02:56:50 +02:00
Thomas Camlong 9df91587b2 Merge branch 'feat/v2-stack-final-runtime-modernization' into feat/v2-stack-final-runtime-mobile 2026-08-09 02:56:39 +02:00
Thomas Camlong db7f1a0374 Merge branch 'feat/v2-stack-final-runtime-dnd' into feat/v2-stack-final-runtime-modernization 2026-08-09 02:56:26 +02:00
Thomas Camlong 1f045b2abf Merge branch 'feat/v2-stack-final-runtime-v2' into feat/v2-stack-final-runtime-dnd 2026-08-09 02:56:11 +02:00
Thomas Camlong 8dad83f720 test(e2e): serialize container specs 2026-08-09 02:55:51 +02:00
Thomas Camlong c237ceff9f Merge branch 'feat/v2-stack-final-runtime-mobile' into feat/v2-stack-final-runtime-assistant 2026-08-09 02:22:19 +02:00
Thomas Camlong cf6c362a4a Merge branch 'feat/v2-stack-final-runtime-modernization' into feat/v2-stack-final-runtime-mobile
# Conflicts:
#	apps/nextjs/src/app/[locale]/boards/[name]/settings/_settings-form.tsx
2026-08-09 02:22:03 +02:00
Thomas Camlong da99dc3080 Merge branch 'feat/v2-stack-final-runtime-dnd' into feat/v2-stack-final-runtime-modernization 2026-08-09 02:21:24 +02:00
Thomas Camlong 52ab119a1f Merge branch 'feat/v2-stack-final-runtime-v2' into feat/v2-stack-final-runtime-dnd 2026-08-09 02:21:13 +02:00
Thomas Camlong bbc23a6c65 fix(settings): avoid nested forms 2026-08-09 02:21:01 +02:00
Thomas Camlong 4e9b7a68ba Merge branch feat/v2-stack-final-runtime-mobile into feat/v2-stack-final-runtime-assistant 2026-08-09 01:47:41 +02:00
Thomas Camlong a75f6e1251 Merge branch feat/v2-stack-final-runtime-modernization into feat/v2-stack-final-runtime-mobile 2026-08-09 01:47:31 +02:00
Thomas Camlong 1db3b2c2cf Merge branch feat/v2-stack-final-runtime-dnd into feat/v2-stack-final-runtime-modernization
# Conflicts:
#	packages/widgets/src/clock/component.tsx
2026-08-09 01:47:20 +02:00
Thomas Camlong 1b663e56ee Merge branch feat/v2-stack-final-runtime-v2 into feat/v2-stack-final-runtime-dnd 2026-08-09 01:46:45 +02:00
Thomas Camlong 5ee14136ac fix(clock): defer live time until mount 2026-08-09 01:46:30 +02:00
Thomas Camlong 58342fabc5 Merge branch 'feat/v2-stack-final-runtime-mobile' into feat/v2-stack-final-runtime-assistant 2026-08-09 01:12:40 +02:00
Thomas Camlong 4da705b752 Merge branch 'feat/v2-stack-final-runtime-modernization' into feat/v2-stack-final-runtime-mobile 2026-08-09 01:12:40 +02:00
Thomas Camlong 4a47fc4551 Merge branch 'feat/v2-stack-final-runtime-dnd' into feat/v2-stack-final-runtime-modernization
# Conflicts:
#	packages/widgets/src/clock/component.tsx
2026-08-09 01:12:32 +02:00
Thomas Camlong 3abdae490e Merge branch 'feat/v2-stack-final-runtime-v2' into feat/v2-stack-final-runtime-dnd 2026-08-09 01:12:12 +02:00
Thomas Camlong 9d3d40d4bd fix(e2e): stabilize initial board hydration 2026-08-09 01:11:38 +02:00
Thomas Camlong b85719bc77 Merge branch 'feat/v2-stack-e2e-fix-mobile' into feat/v2-stack-e2e-fix-assistant 2026-08-09 00:40:17 +02:00
Thomas Camlong 77caf92e16 Merge branch 'feat/v2-stack-e2e-fix-modernization' into feat/v2-stack-e2e-fix-mobile 2026-08-09 00:40:16 +02:00
Thomas Camlong 1367e07f20 test(widgets): stabilize advanced focus hover 2026-08-09 00:39:38 +02:00
Thomas Camlong bbdbae636a Merge branch 'feat/v2-stack-e2e-fix-dnd' into feat/v2-stack-e2e-fix-modernization
# Conflicts:
#	packages/widgets/src/clock/component.tsx
2026-08-09 00:39:38 +02:00
Thomas Camlong 10719bc72e Merge branch 'feat/v2-stack-e2e-fix-v2' into feat/v2-stack-e2e-fix-dnd 2026-08-09 00:37:47 +02:00
Thomas Camlong 48d8517ed1 fix(clock): suppress live timestamp hydration noise 2026-08-09 00:37:33 +02:00
Thomas Camlong bd608f8985 Merge branch 'feat/v2-stack-e2e-fix-mobile' into feat/v2-stack-e2e-fix-assistant 2026-08-09 00:14:56 +02:00
Thomas Camlong d9f988a534 Merge branch 'feat/v2-stack-e2e-fix-modernization' into feat/v2-stack-e2e-fix-mobile 2026-08-09 00:14:44 +02:00
Thomas Camlong 074902ecc8 Merge branch 'feat/v2-stack-e2e-fix-dnd' into feat/v2-stack-e2e-fix-modernization 2026-08-09 00:14:32 +02:00
Thomas Camlong d17752188f Merge branch 'feat/v2-stack-e2e-fix-v2' into feat/v2-stack-e2e-fix-dnd 2026-08-09 00:14:22 +02:00
Thomas Camlong 7619492516 test(v2): make board tour dismissal optional 2026-08-09 00:14:10 +02:00
Thomas Camlong 89b9940c43 Merge branch 'feat/v2-stack-e2e-fix-mobile' into feat/v2-stack-e2e-fix-assistant 2026-08-09 00:11:59 +02:00
Thomas Camlong 60e0b537c1 Merge branch 'feat/v2-stack-e2e-fix-modernization' into feat/v2-stack-e2e-fix-mobile 2026-08-09 00:11:51 +02:00
Thomas Camlong 5e035e14ed Merge commit '9f36be028' into feat/v2-stack-e2e-fix-modernization 2026-08-09 00:11:41 +02:00
Thomas Camlong 9f36be0284 Merge commit 'ca6a019c1' into feat/v2-stack-e2e-fix-dnd
# Conflicts:
#	apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx
#	apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
#	packages/translation/src/lang/en.json
2026-08-09 00:11:33 +02:00
Thomas Camlong ca6a019c1e test(v2): stabilize production E2E flows 2026-08-09 00:10:49 +02:00
Thomas Camlong b9f2054d35 Merge branch 'feat/v2-stack-unit-fix-mobile' into feat/v2-stack-unit-fix-assistant 2026-08-08 23:53:43 +02:00
Thomas Camlong 2182d8df92 test(mcp): review board reset layout tool 2026-08-08 23:53:33 +02:00
Thomas Camlong 50dc17484b Merge branch 'feat/v2-stack-unit-fix-mobile' into feat/v2-stack-unit-fix-assistant 2026-08-08 23:39:29 +02:00
Thomas Camlong 1d9f29b4f1 Merge commit '05eebc097' into feat/v2-stack-unit-fix-mobile 2026-08-08 23:39:29 +02:00
Thomas Camlong 05eebc097f test(mcp): review smart home entity details tool 2026-08-08 23:39:15 +02:00
Thomas Camlong 6b1870e813 Merge branch 'feat/v2-stack-unit-fix-mobile' into feat/v2-stack-unit-fix-assistant 2026-08-08 23:28:52 +02:00
Thomas Camlong 8ea4bc293d Merge commit '0bd9c6f29' into feat/v2-stack-unit-fix-mobile 2026-08-08 23:28:45 +02:00
Thomas Camlong 0bd9c6f290 style(widgets): split query matcher type imports 2026-08-08 23:28:33 +02:00
Thomas Camlong 87b2c0cdcc fix(assistant): preserve lazy board boundaries 2026-08-08 23:24:13 +02:00
Thomas Camlong d79774be1d Merge commit 'c3483a1ec' into feat/v2-stack-unit-fix-assistant 2026-08-08 23:22:00 +02:00
Thomas Camlong c3483a1ec9 test(boards): include layout roles in gutter fixtures 2026-08-08 23:21:55 +02:00
Thomas Camlong 406dc1f14f Merge commit 'bc896c0b0' into feat/v2-stack-unit-fix-mobile 2026-08-08 23:21:40 +02:00
Thomas Camlong bc896c0b03 fix(widgets): keep board graph imports scoped 2026-08-08 23:21:35 +02:00
Thomas Camlong 89d974d7ef Merge mobile boards into Homarr assistant
# Conflicts:
#	package.json
2026-08-08 23:07:39 +02:00
Thomas Camlong d8b46f239c Merge widget modernization into mobile boards 2026-08-08 23:07:10 +02:00
Thomas Camlong 14380e1cdf Merge dnd-kit grid into widget modernization 2026-08-08 23:07:00 +02:00
Thomas Camlong 0ece179558 Merge v2 unit-test project filter into dnd-kit grid 2026-08-08 23:06:54 +02:00
Thomas Camlong cae976b6d8 fix(ci): exclude e2e project from unit gate 2026-08-08 23:06:42 +02:00
Thomas Camlong 1259dd7bf7 Merge mobile boards into Homarr assistant
# Conflicts:
#	package.json
2026-08-08 22:52:10 +02:00
Thomas Camlong 6157a2504b Merge widget modernization into mobile boards 2026-08-08 22:51:24 +02:00
Thomas Camlong c2064faa95 Merge dnd-kit grid into widget modernization 2026-08-08 22:51:17 +02:00
Thomas Camlong 6d1538d3b1 Merge v2 unit-test gate into dnd-kit grid 2026-08-08 22:51:11 +02:00
Thomas Camlong 215f9a7d21 fix(ci): exclude e2e tests from unit gate 2026-08-08 22:50:57 +02:00
Thomas Camlong 882c0dcf07 Merge mobile boards into Homarr assistant 2026-08-08 22:39:29 +02:00
Thomas Camlong 06353862cf Merge widget modernization into mobile boards 2026-08-08 22:39:19 +02:00
Thomas Camlong eae55ce2f0 Merge dnd-kit grid into widget modernization 2026-08-08 22:39:10 +02:00
Thomas Camlong 12412947ee Merge v2 bundle checker into dnd-kit grid 2026-08-08 22:39:00 +02:00
Thomas Camlong dec46f3ee4 fix(ci): detect custom widget runtime chunks 2026-08-08 22:38:42 +02:00
Thomas Camlong 5a62b23cc2 docs: refresh assistant OpenAPI schema 2026-08-08 22:22:09 +02:00
Thomas Camlong adb4d7c6a2 Merge commit '360fd59771bd8e62247b4ea9b1b7cb4839b0105b' into feat/v2-stack-final-assistant 2026-08-08 22:21:07 +02:00
Thomas Camlong 360fd59771 style: format mobile migration metadata 2026-08-08 22:20:48 +02:00
Thomas Camlong e04db94631 Merge commit 'e65c98c29063d80cbfd39c48c699de7018892624' into feat/v2-stack-final-mobile 2026-08-08 22:20:17 +02:00
Thomas Camlong e65c98c290 fix(ci): make modernization independently installable 2026-08-08 22:19:20 +02:00
Thomas Camlong 1b6c4a9022 Merge commit '3246dc4c91656dec3c87267ae074436267ad77ac' into feat/v2-stack-final-modernization
# Conflicts:
#	apps/docs/docs/widgets/downloads/index.mdx
#	apps/docs/docs/widgets/media-server/index.mdx
2026-08-08 22:17:04 +02:00
Thomas Camlong 3246dc4c91 style: format dnd migration metadata 2026-08-08 22:16:16 +02:00
Thomas Camlong 97e89a936c Merge commit 'aaf86a9d1' into feat/v2-stack-final-dnd 2026-08-08 22:15:41 +02:00
Thomas Camlong aaf86a9d13 fix(ci): refresh v2 generated artifacts 2026-08-08 22:15:24 +02:00
Thomas Camlong b49cbb7791 Merge commit '254ed9afbabb957617fc17ebc0122063b0dde803' into feat/v2-stack-e2e-assistant 2026-08-08 22:07:09 +02:00
Thomas Camlong 254ed9afba Merge commit '298937b00ca522855e0ebcb95d8c3adccd34fef7' into feat/v2-stack-e2e-mobile 2026-08-08 22:06:53 +02:00
Thomas Camlong 298937b00c Merge commit '25ac7b6a9' into feat/v2-stack-e2e-modernization 2026-08-08 22:06:36 +02:00
Thomas Camlong 25ac7b6a9d test(e2e): wait for settled grid geometry 2026-08-08 22:06:19 +02:00
Thomas Camlong 84f5001bb2 merge: propagate mobile stack CI fixes 2026-08-08 21:53:28 +02:00
Thomas Camlong 83790f6875 merge: propagate modernization CI fixes 2026-08-08 21:53:09 +02:00
Thomas Camlong b937d4eb18 style: format modernization notes 2026-08-08 21:52:51 +02:00
Thomas Camlong 8d35c0c71f merge: propagate dnd stack CI fix 2026-08-08 21:52:32 +02:00
Thomas Camlong a5ce2e9b7a merge: propagate v2 CI fix 2026-08-08 21:52:16 +02:00
Thomas Camlong 2efc225617 fix(ci): align Workshop pnpm version 2026-08-08 21:52:01 +02:00
Thomas Camlong 0c7cbe17fb merge: propagate mobile stack fixes 2026-08-08 21:42:50 +02:00
Thomas Camlong 69359bb39d merge: propagate modernization fixes 2026-08-08 21:42:31 +02:00
Thomas Camlong f24d757ec8 fix: preserve app links and hover previews 2026-08-08 21:42:13 +02:00
Thomas Camlong 9fd7a8a05b merge: stack assistant on mobile boards 2026-08-08 21:27:56 +02:00
Thomas Camlong a039049084 merge: stack auto mobile boards 2026-08-08 20:59:56 +02:00
Thomas Camlong 9edeba5cd2 merge: stack widget modernization on dnd kit 2026-08-08 20:40:26 +02:00
Thomas Camlong 93276abc8c merge: stack v2 into dnd-kit grid 2026-08-08 20:16:04 +02:00
Thomas Camlong dd63d5600b merge dev into v2 2026-08-08 19:54:57 +02:00
ajnart 25be53244b ci(workshop): restore image publishing 2026-08-08 19:25:35 +02:00
ajnart 2657ff4384 fix: repair restored board layouts 2026-08-08 18:44:32 +02:00
ajnart bc41125b7f fix: guard recursive section rendering 2026-08-08 18:24:38 +02:00
ajnart a9fc3c6faa fix: stabilize responsive layout editing 2026-08-08 18:02:39 +02:00
Thomas 8ab3f136b8 fix(workshop): harden deployment and deep links (#6542) 2026-08-08 17:41:54 +02:00
ajnart 1877b4f9a4 fix: improve layout preview density 2026-08-08 16:45:38 +02:00
ajnart a33eb83655 fix: address responsive layout review 2026-08-08 16:19:26 +02:00
ajnart 991e79310a fix: avoid nested board settings forms 2026-08-08 16:02:40 +02:00
ajnart 43a2b4ac30 Merge remote-tracking branch 'origin/dev' into feat/auto-mobile-boards
# Conflicts:
#	apps/docs/docs/management/boards/index.mdx
#	apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx
2026-08-08 15:47:10 +02:00
ajnart 6728228387 feat: protect responsive board layouts 2026-08-08 15:41:14 +02:00
ajnart 74c7754890 fix(assistant): prevent panel focus capture 2026-08-08 14:26:04 +02:00
ajnart fa9444323b refactor: simplify mobile board layouts 2026-08-08 13:54:42 +02:00
ajnart 7e7b338d18 fix: refine mobile board preview 2026-08-08 13:09:20 +02:00
ajnart 0d1dd02184 fix: address mobile board review 2026-08-08 12:34:10 +02:00
ajnart bc90faf5f2 test: cover tablet mobile boards 2026-08-08 12:23:36 +02:00
ajnart 2c1e697f68 feat: add mobile layout preview 2026-08-08 12:21:53 +02:00
ajnart 5a21df86e2 merge: sync dev changes 2026-08-08 11:56:18 +02:00
Thomas 20fd49f082 fix(assistant): mysql message storage, migration metadata and per-page bundle cost (#6530) 2026-08-08 09:34:16 +02:00
Thomas Camlong 0e3461c6d7 fix: only auto-focus the assistant composer in the floating panel
The composer auto-focused in both the board widget and the floating
panel, so on page load the widget stole focus and scrolled the page to
it, and opening the panel made focus jump between the two composers.
Make auto-focus opt-in via an autoFocusComposer prop: the floating panel
enables it (explicit intent to compose), while the widget leaves it off.
2026-08-07 18:57:44 +02:00
Thomas Camlong 231ac14703 test(integrations): add missing timeout to nextcloud recurring-range test
The 'recurring events that go beyond the range' test was missing the
20s timeout used by every other test in the file, so it fell back to
vitest's default 5s and intermittently timed out against the slow
Nextcloud container. Add the explicit timeout.
2026-08-07 17:52:49 +02:00
Thomas Camlong aa1e6ebdc8 ci: retry flaky container-backed unit tests in fast gate
Integration tests (nzbget, nextcloud, pi-hole, etc.) start Docker
containers and intermittently time out under shared-runner load, which
fails the fast gate and skips the container job that publishes the PR
preview image. Retry failing unit tests twice so transient container
startup failures do not block the image publish.
2026-08-07 17:47:47 +02:00
Thomas Camlong da93adac14 ci: make e2e tests resilient to CI resource contention
E2E specs spin up Docker containers in parallel and intermittently time
out waiting for UI elements under shared-runner load. Limit parallel
workers to 2 and retry flaky failures twice so the container job can
complete and publish the PR preview image.
2026-08-07 16:28:33 +02:00
Thomas Camlong 926b7e2a5e test(e2e): wait for login form hydration before filling
The customJsx E2E test occasionally failed at login with the form
showing 'This field is required' on the username, because the submit
button was clicked while the form was still hydrating (it is disabled
until mounted) and the field values were reset by the re-mount. Wait
for the submit button to become enabled before filling, and assert the
username value stuck before submitting.
2026-08-07 15:49:21 +02:00
Thomas Camlong 17f08c6d14 ci: skip CI for autofix commits
The auto-fix workflow pushes a 'style: auto-fix lint and format' commit
after every user push, which triggers a new CI run that cancels the
in-progress run (concurrency cancel-in-progress) before the container
job can finish and publish the PR preview image. Mark the autofix commit
with [skip ci] so it does not restart CI, letting the user's run
complete and publish the image.
2026-08-07 15:25:03 +02:00
Thomas Camlong 32cad81e1c chore: remove unused assistant subtitle translation 2026-08-07 15:14:23 +02:00
Thomas Camlong eaacfdf7ce chore: simplify assistant header branding
Remove the 'Connected to this Homarr instance' subtitle (and the running
state text) from the assistant panel header, keeping only the robot icon
and title.
2026-08-07 15:07:26 +02:00
Thomas Camlong 96659fb543 chore: shrink demo assistant widget to 3x3 2026-08-07 15:04:24 +02:00
Thomas d625640051 feat: add assistant widget to demo dashboard (#6529) 2026-08-07 15:02:03 +02:00
Thomas b14756874e fix: handle missing message in assistant generation telemetry (#6528) 2026-08-07 14:59:59 +02:00
ajnart 6bbb786ede style: auto-fix lint and format 2026-08-07 12:54:24 +00:00
Thomas Camlong 31d9e8d582 fix: prevent infinite render loop in assistant telemetry
RequestTelemetry derived thread message metadata inside the useAuiState
selector with messages.map(), returning a new array on every render.
useSyncExternalStore treats the new reference as a changed snapshot,
causing an infinite re-render loop (React error #185) while an assistant
message streams. Select the stable messages reference and memoize the
metadata derivation instead.
2026-08-07 14:53:05 +02:00
Thomas bc12aba433 Merge branch 'dev' into feat/homarr-assistant 2026-08-06 21:53:57 +02:00
Thomas eca8b4286d feat: mock Homarr Assistant in demo mode and fix board page crash (#6524) 2026-08-06 21:33:03 +02:00
ajnart fce950d406 test: wait for Home Assistant API readiness 2026-08-04 18:31:23 +02:00
ajnart bcdd56f714 feat: polish assistant conversations 2026-08-04 18:22:18 +02:00
ajnart 8e557fb9fd feat: improve assistant interactions 2026-08-04 13:21:39 +02:00
Thomas Camlong 4bb862ee76 fix: stabilize advanced widget interactions 2026-08-03 21:45:00 +02:00
Thomas Camlong 0c5251a971 fix: finish widget modernization audit 2026-08-03 21:01:55 +02:00
Thomas Camlong 454752e46d fix: address widget review feedback 2026-08-03 20:04:56 +02:00
Thomas Camlong 596bfc317d fix: scope advanced preview controls 2026-08-03 19:42:43 +02:00
Thomas Camlong 97444d209a ci: limit turbo concurrency 2026-08-03 19:17:28 +02:00
Thomas Camlong ee51227a55 fix: restore notifications query scope 2026-08-03 18:53:26 +02:00
Thomas Camlong db7c850596 fix: remove stale notifications import 2026-08-03 18:52:02 +02:00
ajnart 30344bdaae style: auto-fix lint and format 2026-08-03 16:46:35 +00:00
Thomas Camlong 7847eea1a3 fix: refine widget interactions 2026-08-03 18:45:16 +02:00
ajnart 1642a862d9 stabilize hydrated login actions 2026-08-03 18:37:05 +02:00
ajnart 62f5c1cbc3 stabilize integration router tests 2026-08-03 18:14:25 +02:00
ajnart 5e857c0e0a fix Docker dependency fetch scripts 2026-08-03 17:55:03 +02:00
ajnart 5da70164a6 style: auto-fix lint and format 2026-08-03 15:40:15 +00:00
ajnart fbe381cbb5 style: auto-fix lint and format 2026-08-03 15:39:36 +00:00
Thomas af25e78120 Merge branch 'dev' into feat/auto-mobile-boards 2026-08-03 17:39:10 +02:00
Thomas 8d98408cfe Merge branch 'dev' into feat/dnd-kit-grid 2026-08-03 17:38:51 +02:00
Thomas 9234ca0f32 Merge branch 'dev' into feat/widget-modernization 2026-08-03 17:38:28 +02:00
ajnart f66d982ff3 fix duplicate release success flag 2026-08-03 17:35:28 +02:00
ajnart cd16ab8e84 Merge remote-tracking branch 'origin/dev' into feat/homarr-assistant 2026-08-03 17:34:33 +02:00
ajnart eaa7fd8bc3 clarify future Homarr provider 2026-08-03 17:25:14 +02:00
ajnart 4b62cbfb4b fix integration permission checks 2026-08-03 17:25:14 +02:00
Thomas Camlong 61a2f8cb6c feat: modernize widget experience 2026-08-03 17:18:22 +02:00
ajnart 5155d7b742 fix merged dependency lockfile 2026-08-03 17:14:29 +02:00
Thomas Camlong 596833f227 fix container header spacing 2026-08-03 17:13:02 +02:00
ajnart 56a6d4989a Merge remote-tracking branch 'origin/dev' into feat/homarr-assistant
# Conflicts:
#	pnpm-lock.yaml
2026-08-03 17:08:45 +02:00
ajnart c43ab2ee56 fix assistant provider icon contrast 2026-08-03 17:05:12 +02:00
ajnart f7b291b465 add OpenRouter web search option 2026-08-03 16:59:27 +02:00
Thomas Camlong cc71ab8cd9 fix: contain board canvas overflow 2026-08-03 16:46:43 +02:00
ajnart 35673f113e fix assistant widget configuration 2026-08-03 16:43:08 +02:00
ajnart bd3e4c33f3 enhance assistant batch responses 2026-08-03 15:48:41 +02:00
ajnart 6a98036c50 show assistant icon search previews 2026-08-03 15:36:10 +02:00
ajnart 59bed6b995 fix assistant streaming scroll lock 2026-08-03 15:30:10 +02:00
ajnart 5a3022a073 integrate assistant custom widget workflow 2026-08-03 15:22:08 +02:00
Thomas Camlong 49d623e5c2 fix: defer dependency scripts during container fetch 2026-08-03 15:18:07 +02:00
ajnart ab21a9fece style: auto-fix lint and format 2026-08-03 12:50:22 +00:00
Thomas Camlong 41fffbb79b Merge dev into feat/dnd-kit-grid 2026-08-03 14:48:31 +02:00
Thomas 36ac1b40ff Merge branch 'dev' into feat/auto-mobile-boards 2026-08-03 14:38:45 +02:00
Thomas Camlong 08da71d67f fix: contain collapsed layout and widget overflow 2026-08-03 14:27:45 +02:00
ajnart 65ff0ecb34 docs: add widget modernization audit 2026-08-03 14:27:30 +02:00
ajnart abc17e7c2a fix assistant prompt submission 2026-08-03 14:12:13 +02:00
ajnart c81a3b9432 refresh views after assistant changes 2026-08-03 13:38:20 +02:00
Thomas Camlong 80d46b0d85 restore context menu focus 2026-08-03 13:28:12 +02:00
Thomas Camlong 6e15f8bcf8 fix context menu keyboard focus 2026-08-03 13:24:13 +02:00
Thomas Camlong 294217fcba fix board E2E login race 2026-08-03 13:09:56 +02:00
ajnart 8dd1d70900 improve assistant question placement 2026-08-03 13:07:07 +02:00
Thomas Camlong 3c99c75ad7 fix SVG focus scrolling 2026-08-03 12:51:10 +02:00
ajnart 9e0a6a758f fix assistant automatic approvals 2026-08-03 12:34:54 +02:00
Thomas Camlong 20dbdcedf2 fix focused pane scrolling 2026-08-03 12:25:56 +02:00
Thomas Camlong a2f5668c44 fix focus scroll layering 2026-08-03 12:21:31 +02:00
Thomas Camlong 09e9cd4388 fix widget focus interactions 2026-08-03 12:15:12 +02:00
Thomas Camlong 5c9fd28ae1 test: wait for resized grid geometry 2026-08-02 19:27:09 +02:00
Thomas Camlong 8f0c9435ff test: stabilize board grid authentication 2026-08-02 19:14:40 +02:00
Thomas Camlong 96e92485a5 fix: align responsive widget actions 2026-08-02 18:10:25 +02:00
Thomas Camlong f9aae23f46 fix: keep session labels readable 2026-08-02 17:41:27 +02:00
Thomas Camlong fd024e32d2 fix: simplify widget feedback 2026-08-02 17:39:28 +02:00
Thomas Camlong 5010ecf188 fix: harden widget error feedback 2026-08-02 17:34:52 +02:00
Thomas Camlong f0d8e97f8d fix: address final widget review 2026-08-02 17:25:02 +02:00
ajnart 8f81de775e chore: remove unused ai sdk dependency 2026-08-02 16:57:11 +02:00
Thomas Camlong ed8ef15f12 fix: localize widget chart formatting 2026-08-02 16:48:34 +02:00
ajnart 8a2375b3ef fix: address assistant review feedback 2026-08-02 16:45:14 +02:00
Thomas Camlong d97f1bd34b fix: align widget accessibility and locales 2026-08-02 16:42:08 +02:00
Thomas Camlong d86293de0f fix: canonicalize widget date locales 2026-08-02 16:32:59 +02:00
Thomas Camlong 54e444d69d fix: close final widget review gaps 2026-08-02 16:26:04 +02:00
Thomas Camlong d5c3aebd26 fix: harden widget review follow-ups 2026-08-02 16:10:16 +02:00
ajnart 1777ca4446 style: auto-fix lint and format 2026-08-02 13:58:01 +00:00
ajnart cf6aefa48a fix: isolate assistant conversation state 2026-08-02 15:57:05 +02:00
Thomas Camlong 56e3bd1cf2 fix: smooth container collapse behavior 2026-08-02 15:57:05 +02:00
Thomas Camlong 7b7045202d fix: address widget review findings 2026-08-02 14:35:01 +02:00
ajnart d7969e1ec1 fix: harden assistant interactive tools 2026-08-02 14:20:40 +02:00
Thomas Camlong 1e0bf40040 feat: refine widget interactions 2026-08-02 14:12:18 +02:00
Thomas Camlong bdffb63ebc Merge remote-tracking branch 'origin/dev' into feat/widget-modernization 2026-08-02 14:06:03 +02:00
ajnart eff3168f1c feat: improve assistant tool workflows 2026-08-02 11:24:11 +02:00
ajnart 4db4d947db style: auto-fix lint and format 2026-08-02 09:04:11 +00:00
Thomas Camlong 0755fc1e26 fix: refine container interactions 2026-08-02 11:02:55 +02:00
ajnart 81a2f18c7c fix: map legacy media server options 2026-08-02 10:20:40 +02:00
ajnart 9eaadd83fb Merge remote-tracking branch 'origin/dev' into feat/homarr-assistant 2026-08-02 10:18:52 +02:00
Thomas Camlong d460ac8bbb fix: secure widget navigation links 2026-08-02 10:01:36 +02:00
Thomas Camlong 1d73513989 fix: polish widget failure feedback 2026-08-02 09:50:08 +02:00
Thomas Camlong e3f679237d fix: polish board grid interactions 2026-08-02 09:36:33 +02:00
Thomas Camlong 45ff97972c fix: address final widget review findings 2026-08-02 09:27:31 +02:00
ajnart 8bb0ddef42 fix: secure assistant generation telemetry 2026-08-02 09:09:01 +02:00
Thomas Camlong 36b75a071b fix: complete widget review cleanup 2026-08-02 09:05:02 +02:00
ajnart 04520cf67c feat: enrich assistant request telemetry 2026-08-02 08:51:48 +02:00
Thomas Camlong 3f7d934fa5 fix: address remaining widget review feedback 2026-08-02 08:44:00 +02:00
ajnart d562706c8b fix: sync assistant runtime preferences 2026-08-02 08:27:31 +02:00
ajnart 3245356f0f fix: align assistant management layout 2026-08-02 08:23:58 +02:00
Thomas Camlong 76974f676d fix: allow dynamic database schema access 2026-08-02 08:19:46 +02:00
ajnart 0d23bafa3d fix: hide inactive assistant scroll control 2026-08-02 08:18:05 +02:00
Thomas Camlong 5ddbf184fd ci: limit fast gate concurrency 2026-08-02 08:10:14 +02:00
Thomas Camlong c61695b142 fix: make integration warnings keyboard accessible 2026-08-02 08:09:55 +02:00
Thomas Camlong b9b938c658 fix: address board grid CI and review 2026-08-02 08:05:25 +02:00
Thomas Camlong 6f27ede8bf fix: complete widget modernization review 2026-08-02 08:05:04 +02:00
Thomas Camlong 79ae42f0c3 fix: sync widget modernization with dev 2026-08-02 01:37:01 +02:00
Thomas Camlong c754946089 fix: address widget review feedback 2026-08-01 23:44:17 +02:00
ajnart ab7d385a13 style: auto-fix lint and format 2026-08-01 21:38:41 +00:00
Thomas c53b38a738 Merge branch 'dev' into feat/dnd-kit-grid 2026-08-01 23:37:27 +02:00
Thomas Camlong d0c7ba401f fix: address board grid review 2026-08-01 23:26:02 +02:00
Thomas Camlong d94cadd3e7 feat: rebuild board grid with dnd-kit 2026-08-01 22:30:40 +02:00
ajnart cce3e455fa style: auto-fix lint and format 2026-08-01 19:47:19 +00:00
Thomas Camlong b28beec2c6 fix: refine widget modernization 2026-08-01 21:45:01 +02:00
Thomas Camlong d421046d15 feat: modernize dashboard widgets 2026-08-01 21:14:33 +02:00
ajnart b7034e59c1 fix: polish assistant widget interactions 2026-08-01 18:25:48 +02:00
ajnart d6c1a87a03 test: wait for grid reconciliation between drags 2026-08-01 17:56:09 +02:00
ajnart d111f7e981 fix: correct assistant widget docs metadata 2026-08-01 15:00:03 +02:00
ajnart 28b6321b1c style: auto-fix lint and format 2026-08-01 12:52:44 +00:00
ajnart 0a8e6ed194 feat: add assistant conversation widget 2026-08-01 14:51:20 +02:00
ajnart 50907c7289 fix: clarify board grid editing affordances 2026-08-01 14:13:07 +02:00
ajnart 6e28712ea7 fix: simplify assistant shortcut 2026-08-01 11:09:10 +02:00
ajnart 5458e7d647 feat: add assistant board styling tools 2026-08-01 10:39:41 +02:00
ajnart a5fa6d3afe fix: restore assistant approval flows 2026-08-01 09:54:11 +02:00
ajnart 9c8fa8158b fix: improve board grid interactions 2026-08-01 00:43:36 +02:00
Thomas Camlong e6e413bcb7 test: target visible layout preview 2026-08-01 00:43:36 +02:00
Thomas Camlong 81d14e39ab test: tolerate sticky rail subpixel rounding 2026-08-01 00:43:36 +02:00
Thomas Camlong f207587791 test: avoid grid drag rounding boundary 2026-08-01 00:43:36 +02:00
Thomas Camlong 5cc2df19ed fix: satisfy database namespace lint 2026-08-01 00:43:36 +02:00
ajnart 0a80d70730 style: auto-fix lint and format 2026-08-01 00:43:36 +02:00
ajnart 25c9955cc6 feat: rebuild board grid experience 2026-08-01 00:43:28 +02:00
Thomas d0e28a5fc9 Merge branch 'dev' into feat/homarr-assistant 2026-07-31 13:55:31 +02:00
Thomas 1b55514d98 Merge branch 'dev' into feat/homarr-assistant 2026-07-31 13:32:03 +02:00
Thomas 557ebe0a2f fix: harden v2 custom widgets and Workshop (#6480) 2026-07-31 13:28:53 +02:00
ajnart 9a2d6f692e feat: expose assistant global shortcuts 2026-07-31 06:58:39 +02:00
ajnart bf53a2a262 fix: keep assistant menu selection visible 2026-07-31 06:32:57 +02:00
ajnart 1d15b614f3 refactor: group assistant runtime controls 2026-07-31 06:00:49 +02:00
ajnart e6981a7f44 fix: invoke mutations before approval 2026-07-30 23:31:24 +02:00
ajnart 48dbdcd835 feat: redesign assistant management 2026-07-30 22:59:27 +02:00
ajnart 5fdf408378 docs: explain assistant search focus 2026-07-30 22:04:45 +02:00
ajnart 38e7d2c15f style: auto-fix lint and format 2026-07-30 20:02:54 +00:00
ajnart 82b146bdf6 feat: improve assistant confirmations 2026-07-30 22:01:21 +02:00
ajnart 2aefa23c36 feat: improve assistant background controls 2026-07-30 18:33:54 +02:00
ajnart 19cc115730 refactor: simplify assistant message controls 2026-07-30 17:33:43 +02:00
ajnart 429b587529 fix: complete assistant markdown lockfile 2026-07-30 17:03:14 +02:00
ajnart 93059c1e48 feat: render assistant content 2026-07-30 16:56:43 +02:00
ajnart af75bcd741 fix: send provider model ids 2026-07-30 16:00:09 +02:00
ajnart 6bd331bc73 fix: preserve assistant trigger focus 2026-07-30 14:33:29 +02:00
ajnart ee08870925 fix: address assistant capability review 2026-07-30 14:22:01 +02:00
ajnart 33e23021ae feat: expand assistant interaction capabilities 2026-07-30 14:09:15 +02:00
ajnart 9e6a0c7748 refactor: remove assistant chat archiving 2026-07-30 12:08:21 +02:00
ajnart 212ce64c04 fix: polish assistant mobile layout 2026-07-30 11:47:30 +02:00
ajnart 828f2ebecd fix: harden assistant floating panel 2026-07-30 11:17:08 +02:00
ajnart 0434f6ac10 feat: streamline assistant interactions 2026-07-30 11:06:16 +02:00
ajnart 7f946e45f4 fix: surface assistant stream errors 2026-07-30 10:01:36 +02:00
ajnart c10395fe5d fix: keep assistant in user menu 2026-07-30 09:37:52 +02:00
ajnart f3bdac4d23 fix: clarify assistant credential removal 2026-07-30 09:18:34 +02:00
ajnart be0496af5a fix: invalidate persisted assistant model state 2026-07-30 08:59:01 +02:00
ajnart 5da5433b58 fix: invalidate assistant models on discovery changes 2026-07-30 08:49:51 +02:00
ajnart 703c27c639 fix: gate assistant models on saved connection 2026-07-30 08:42:41 +02:00
ajnart 676ba6c885 fix: clear assistant credentials on destination changes 2026-07-30 08:34:44 +02:00
ajnart aa83fe4986 feat: promote assistant configuration 2026-07-30 08:25:00 +02:00
ajnart 1c3aac5e82 feat: expand assistant providers and polish experience 2026-07-30 00:39:22 +02:00
ajnart 7fec6c8a07 test: wait for Home Assistant restart 2026-07-29 21:18:35 +02:00
ajnart b025cc593c fix: label board edit mode control 2026-07-29 20:58:09 +02:00
ajnart 60cc1c1e29 fix: initialize tablet mobile boards consistently 2026-07-29 20:38:12 +02:00
ajnart b5ffefe707 fix: keep mobile board widgets unchanged 2026-07-29 20:21:59 +02:00
ajnart e2732dce2c Revert "feat: improve mobile board experience"
This reverts commit 7c0e98c55b.
2026-07-29 20:07:37 +02:00
ajnart c1819d6fdc Revert "fix: handle mobile logout failures"
This reverts commit 9f0969ec22.
2026-07-29 20:07:27 +02:00
ajnart 5c4cc81b05 Revert "feat: refine mobile dashboard experience"
This reverts commit 315520beca.
2026-07-29 20:07:17 +02:00
ajnart bbc560706a Revert "style: auto-fix lint and format"
This reverts commit 4bd0d8e8dd.
2026-07-29 20:06:59 +02:00
ajnart 84b334abab Revert "fix: safely extract notebook summaries"
This reverts commit fa3d0375c5.
2026-07-29 20:06:59 +02:00
ajnart 38a710efe3 Revert "fix: address mobile dashboard review findings"
This reverts commit 1039bb18b3.
2026-07-29 20:06:59 +02:00
ajnart 43e95ff185 Revert "fix: collect mobile board e2e test"
This reverts commit 6dbfbb1417.
2026-07-29 20:06:59 +02:00
ajnart d6bb6542c4 Revert "fix: preserve mobile section focus"
This reverts commit 7ce1081161.
2026-07-29 20:06:59 +02:00
ajnart 55ee5ef063 Revert "fix: layer mobile widget details above header"
This reverts commit a540f07920.
2026-07-29 20:06:59 +02:00
ajnart ee3b45b73a Revert "fix: keep interface layers server safe"
This reverts commit cf83e0d849.
2026-07-29 20:06:59 +02:00
ajnart b612fb9a89 Revert "feat: complete automatic mobile board UX"
This reverts commit 7959c20d88.
2026-07-29 20:06:47 +02:00
ajnart dba9a250ff Revert "fix: harden automatic mobile boards"
This reverts commit d0b9348b86.
2026-07-29 20:06:32 +02:00
ajnart f90fb4512b fix: derive assistant approval key at runtime 2026-07-28 23:47:07 +02:00
ajnart 92a2be2b65 merge: sync dev changes 2026-07-28 23:45:19 +02:00
ajnart 97fc9dda4c ci: limit turbo concurrency 2026-07-28 23:29:45 +02:00
ajnart 5c60489503 fix: hide closed assistant history 2026-07-28 23:19:31 +02:00
Thomas cfad8dd642 Merge branch 'dev' into feat/homarr-assistant 2026-07-28 23:13:43 +02:00
ajnart 36ae9f93ee fix: harden assistant integration 2026-07-28 22:56:28 +02:00
ajnart 82987d1f69 ci: limit fast gate concurrency 2026-07-28 22:48:00 +02:00
ajnart 00e8790ec2 feat: add Homarr assistant 2026-07-28 21:33:17 +02:00
ajnart d0b9348b86 fix: harden automatic mobile boards 2026-07-28 21:28:53 +02:00
ajnart 7959c20d88 feat: complete automatic mobile board UX 2026-07-28 19:39:13 +02:00
ajnart cf83e0d849 fix: keep interface layers server safe 2026-07-28 19:39:13 +02:00
ajnart a540f07920 fix: layer mobile widget details above header 2026-07-28 19:39:13 +02:00
ajnart 7ce1081161 fix: preserve mobile section focus 2026-07-28 19:39:13 +02:00
ajnart 6dbfbb1417 fix: collect mobile board e2e test 2026-07-28 19:39:12 +02:00
ajnart 1039bb18b3 fix: address mobile dashboard review findings 2026-07-28 19:39:12 +02:00
ajnart fa3d0375c5 fix: safely extract notebook summaries 2026-07-28 19:39:12 +02:00
ajnart 4bd0d8e8dd style: auto-fix lint and format 2026-07-28 19:39:12 +02:00
ajnart 315520beca feat: refine mobile dashboard experience 2026-07-28 19:39:12 +02:00
ajnart 9f0969ec22 fix: handle mobile logout failures 2026-07-28 19:39:12 +02:00
ajnart 7c0e98c55b feat: improve mobile board experience 2026-07-28 19:39:12 +02:00
ajnart bc57f9579d fix: align mobile root section ordering 2026-07-28 19:39:12 +02:00
ajnart e2f9fa871a fix: address board review and CI feedback 2026-07-28 19:39:12 +02:00
ajnart f133e09dc6 feat: add automatic mobile board layout 2026-07-28 19:39:12 +02:00
Thomas e8a2c87c2d Delete review.md 2026-07-23 02:13:20 +02:00
Thomas 4f6e335f72 fix(custom-widgets): address final v2 review feedback (#6451) 2026-07-23 02:11:32 +02:00
Thomas df967751f6 fix(custom-widgets): address v2 merge review (#6446) 2026-07-23 01:55:15 +02:00
Thomas 6aceb2fd1f feat(custom-widgets): complete Custom JSX v2 (#6390) 2026-07-22 20:47:19 +02:00
Thomas 60cbd7bb7a Merge branch 'dev' into v2 2026-07-18 23:00:30 +02:00
Thomas c0f1d6624c fix(custom-widgets): fix v2 editor crash and definition-not-found race (#6377) 2026-07-17 14:39:29 +02:00
Thomas Camlong 67d146220a test(integrations): wait for Home Assistant restart 2026-07-16 16:25:48 +02:00
Thomas Camlong 41db737905 ci: publish multi-platform preview images 2026-07-16 16:16:01 +02:00
Thomas Camlong 3b128585a3 ci: remove memory regression test 2026-07-16 15:01:27 +02:00
Thomas Camlong cdaf2a838c fix(ci): preserve memory-test encryption key 2026-07-16 14:52:40 +02:00
Thomas Camlong 926a6856fb test(integrations): allow recurring calendar setup 2026-07-16 14:34:07 +02:00
Thomas Camlong c6160da222 fix(ci): stabilize affected workspace gate 2026-07-16 14:22:01 +02:00
Thomas Camlong e729dfd891 fix(ci): prevent autofix race 2026-07-16 14:13:39 +02:00
Thomas Camlong eb71f936c6 fix(custom-widgets): remove stale form helpers 2026-07-16 14:13:39 +02:00
ajnart c10ebbc863 style: auto-fix lint and format [skip ci] 2026-07-16 08:22:51 +00:00
Thomas 106c3bfa90 Merge branch 'dev' into v2 2026-07-16 10:21:40 +02:00
Thomas Camlong 27a6467896 fix(ci): don't cancel CI on autofix pushes, guard format step 2026-07-15 16:47:28 +02:00
ajnart 8331a58471 style: auto-fix lint and format [skip ci] 2026-07-15 14:20:37 +00:00
Thomas Camlong ec74c36a6a fix(oxlint): remove unsupported option from docs child config 2026-07-15 16:19:12 +02:00
Thomas Camlong 487d98275c fix(ci): autofix commits skip CI to prevent race cancellation 2026-07-15 16:17:59 +02:00
Thomas Camlong 187a479bd5 fix(ci): scope format check to only PR-changed files 2026-07-15 15:57:42 +02:00
Thomas Camlong 0bfe6ddabe fix(ci): don't fail fast-gate on format check (auto-fix handles it) 2026-07-15 15:55:00 +02:00
ajnart 51d8a3cc53 style: auto-fix lint and format 2026-07-15 13:54:18 +00:00
Thomas Camlong 06db42129c fix(ci): remove coverage step, fix auto-fix to format all affected workspaces 2026-07-15 15:47:11 +02:00
Thomas Camlong a11583feb1 Revert "style: format all files and remove coverage report step"
This reverts commit 446931bc0a.
2026-07-15 15:44:49 +02:00
Thomas Camlong 446931bc0a style: format all files and remove coverage report step 2026-07-15 15:42:13 +02:00
Thomas Camlong 34bcc62415 fix: disable cpu-features native build (incompatible with Node 24 V8 API) 2026-07-15 15:35:43 +02:00
Thomas Camlong a2e3c5d3bc style: fix formatting in v2 files 2026-07-15 15:34:15 +02:00
Thomas Camlong ed9646b0c6 fix: regenerate pnpm-lock.yaml after dev merge 2026-07-15 15:25:52 +02:00
Thomas Camlong e0de370340 Merge branch 'dev' into v2 2026-07-15 15:17:18 +02:00
homarr-renovate[bot] 7e5453d47c chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.7 (#6343)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-15 12:16:38 +00:00
Thomas 3b2fc2527e feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304) 2026-07-15 14:06:02 +02:00
Thomasandhomarr-renovate[bot] 0a0b3a12c9 bring dev changes to v2 (#6353)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-15 12:16:16 +02:00
Thomas ba263dbe47 Delete .opencode directory 2026-07-15 12:14:46 +02:00
Thomas Camlong 8a7c4875bf Revert "chore(merge): resolve v2 conflicts"
This reverts commit 377f2f6a20, reversing
changes made to 7e4cb0201b.
2026-07-15 12:12:33 +02:00
Thomas Camlong 377f2f6a20 chore(merge): resolve v2 conflicts 2026-07-15 12:09:51 +02:00
Thomasandcopilot-swe-agent[bot] c69e33ac8c feat: community workshop for custom widgets and CSS (#5984)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-15 11:53:15 +02:00
Thomas Camlong 121a83040d Setup v2 pre-release branch 2026-07-13 18:14:19 +02:00
653 changed files with 10481 additions and 27497 deletions

No files matched your search

+2
View File
@@ -66,6 +66,8 @@ homarr/
│ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context
│ ├── old-import/ # Legacy Homarr import
│ ├── old-schema/ # Legacy Homarr zod schemas
│ └── cli/ # Node CLI for ops (brocli)
├── tooling/
│ ├── typescript/ # Base tsconfig
+19 -9
View File
@@ -263,6 +263,15 @@ Even hidden options need translation keys or the translation test fails:
"widget.mywidget.option.columnWidths.label": "Column widths"
```
### Old-import mappings
Add to `packages/old-import/src/widgets/options.ts`:
```typescript
columnOrder: () => undefined,
columnWidths: () => undefined,
```
### Dynamic storeKey
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
@@ -601,12 +610,13 @@ if (isEditMode) rowContextMenuHandler = undefined;
4. Add CSS file for transparent backgrounds (if widget context)
5. Add responsive column visibility (if widget context)
6. Add hidden options + hydration logic (if column persistence needed)
7. Add translation keys (including hidden option labels)
8. Add context menu if row actions exist
9. Add row expansion if detail view exists
10. Add error handling on all mutations
11. Add accessibility attributes
12. Test at multiple widget widths
13. Update docs in `apps/docs/docs/widgets/` if applicable
14. Run `pnpm turbo typecheck`
15. Run translation spec — catches missing keys
7. Add old-import mappings for new options
8. Add translation keys (including hidden option labels)
9. Add context menu if row actions exist
10. Add row expansion if detail view exists
11. Add error handling on all mutations
12. Add accessibility attributes
13. Test at multiple widget widths
14. Update docs in `apps/docs/docs/widgets/` if applicable
15. Run `pnpm turbo typecheck` — catches old-import mismatches
16. Run translation spec — catches missing keys
-6
View File
@@ -95,12 +95,6 @@ jobs:
- name: Validate checked-in OpenAPI schema
run: pnpm check:openapi
- name: Validate feature contracts
run: pnpm check:feature-contracts
- name: Test feature platform
run: pnpm test:feature-platform
- name: Validate Custom Widget bundle budget
run: |
pnpm --filter nextjs build
-8
View File
@@ -84,11 +84,3 @@ apps/docs/.docusaurus
apps/docs/build
.pnpm-store
benchmark-results
# local performance investigation (never ship benchmark data or restored backups)
.widget-performance/
.bench/
homarr-backup*.zip
*.sqlite-wal
*.sqlite-shm
/scripts/benchmarks/
+2
View File
@@ -40,6 +40,8 @@ homarr/
│ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context
│ ├── old-import/ # Legacy Homarr import
│ ├── old-schema/ # Legacy Homarr zod schemas
│ └── cli/ # Node CLI for ops (brocli)
├── tooling/
│ ├── typescript/ # Base tsconfig
+1 -1
View File
@@ -54,7 +54,7 @@ For a single consolidated file with all content, use:
- [How to integrate custom input with use-form hook?](https://mantine.dev/llms/q-custom-input-use-form.md): Learn how to add use-form support for custom inputs
- [How to prevent Modal from closing?](https://mantine.dev/llms/q-how-to-prevent-modal-from-closing.md): Learn how to prevent Modal from closing when the user clicks outside of it or presses the Escape key
- [How to scroll to the top of the form if the form is submitted with errors?](https://mantine.dev/llms/q-scroll-to-the-top-of-the-form.md): Learn how to call a function when the form is submitted with errors
- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update `@mantine/*`, `@mantinex/*` and `postcss-preset-mantine` dependencies manually or with ncu script
- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/_, @mantinex/_ and postcss-preset-mantine dependencies manually or with ncu script
- [How to use Dropzone with @mantine/form?](https://mantine.dev/llms/q-how-to-use-dropzone-with-form.md): Learn how to use Dropzone with @mantine/form to handle file selection state
- [How to use Mantine template on GitHub?](https://mantine.dev/llms/q-templates-usage.md): Learn how to create a new repository based on Mantine template
- [I get hydration warning about data-mantine-color-scheme attribute, what does it mean?](https://mantine.dev/llms/q-color-scheme-hydration-warning.md): Learn how hydration works and how to fix warnings
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

@@ -13,6 +13,8 @@ This will be a one time occurrence and version > ``1.0`` will have less breaking
This time more important than every; **read the breaking changes** list and **create a backup** before updating.
Some third party installation methods may take multiple days or weeks to update to the new version.
See [this guide on how to import your previous Homarr dashboard](/docs/getting-started/after-the-installation#importing-a-zip-from-version-before-100)
## Changes at a glance
- **Asynchronous integrations system**: In versions prior to ``1.0``, we fetched data upon each request synchronously.
This means that the user had to wait for the requests to finish so any data would appear.
@@ -10,11 +10,12 @@ If you are still on a version before 0.15.10, please update to 0.15.10 where we
<!-- truncate -->
## Preserve your old installation
## Export your data
Homarr 1.0 is a complete rewrite and cannot reuse the old Docker mounts. Keep the 0.15 installation and its data intact while you build the replacement. The historical `Management` > `Tools` > `Migrate to 1.0` export can still be kept as a record, but current Homarr releases cannot import that ZIP or its secret key.
Plan to recreate your apps, integrations, and boards manually in the new installation. A SQLite database backup can be restored from the onboarding welcome screen only when it was created by a compatible current Homarr release; it is not a conversion path for a 0.15 database.
As 1.0 is a complete rewrite of Homarr and therefor not compatible with the old docker mounts, we need to export your data first.
For this you can navigate to the `Migrate to 1.0` page which you can find under `Management` > `Tools` > `Migrate to 1.0`.
Here you can select which things you want to export and download the zip file.
It will also show you a secret key which you will need to import the secret data like user password hashes, salts and integration secrets.
![Export page](./img/old-homarr-export-data.png)
@@ -27,15 +28,28 @@ Please note that if you are using docker you need to specify a diffrent external
So in your compose file the ports would be `7576:7575` instead of `7575:7575`.
## Import your data
Once the installation is completed you can access the new installation. During the onboarding you'll need to select `Import from Homarr before 1.0`.
:::warning Legacy guide
![Onboarding initial step](./img/onboarding-step-1.png)
The pre-1.0 ZIP importer is no longer included in current Homarr releases. Keep the old installation available until you have manually verified the replacement. Compatible SQLite backups from current releases can still be restored from the onboarding welcome screen.
After that you can upload the zip file you downloaded in the first step.
:::
![Onboarding import dropzone](./img/old-homarr-import-dropzone.png)
After that you'll get a list with all your boards that can be imported. You can configure how it should be imported and can select which screensizes it should import.
![Onboarding import list](./img/old-homarr-import-choose-imported-data.png)
Once you have selected everything you can click on `Confirm import and continue` which will open the encryption key input dialog where you can input the secret key you got during the export.
![Onboarding import key input](./img/old-homarr-import-decryption-token.png)
After that you can continue the onboarding and you should see all your boards and data in the new installation.
See [After the installation](https://homarr.dev/docs/getting-started/after-the-installation) for more information.
## Remove the old installation
After you are happy with the new installation and everything is working as expected you can remove the old installation.
Please make sure to backup the data before you remove the old installation.
Also once the port 7575 is free again you can change the port mapping of the new installation to `7575:7575` if you want to.
Also once the port 7575 is free again you can change the port mapping of the new installation to `7575:7575` if you want to.
@@ -60,70 +60,6 @@ default data; use `pnpm db:seed` to seed it explicitly later.
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
[Workshop development guide](../../workshop/development).
### Scaffold a native feature
Run `pnpm generate:feature` and choose `native-integration`, `widget`, or `integration-widget`. The generator creates
implementation, focused test, documentation, and English translation skeletons and updates the current explicit
registries. It stops before writing if a target exists, a registry anchor is ambiguous, or a supported integration is
unknown.
The generated code is a starting contract, not a finished feature. Replace the placeholder request, UI, tests, secrets,
categories, configuration, error states, and documentation with behavior specific to the service. Then run:
```sh
pnpm check:feature-contracts
pnpm test:feature-platform
```
The contract check reports the exact registry, docs path, translation, support mapping, or package export that needs
repair. It intentionally follows today's explicit registries; a future canonical feature descriptor can replace those
patches without changing the contributor-facing generator names.
Integration-capable widgets declare their server-safe relationship in `nativeFeatureCapabilities`. Homarr derives the
widget-to-integration map, reverse integration-to-widget map, optional-connection set, and server selection limits from
that descriptor. Widget definitions still mirror the supported list for client-side type inference; the focused widget
manifest test and `check:feature-contracts` prevent drift until those definition types can consume the descriptor
directly.
New integration skeletons also include response fixtures using `simulateResponseContractAsync`. Replace the generated
minimal schema and fixtures with representative upstream payloads: at least one successful response, important optional
or version-specific shapes, and a malformed response that must be rejected. The simulator uses the real parser without
network access and reports every fixture mismatch, so parser contracts can be reviewed before credentials are available.
### Review native feature states
In a development server, administrators can open `/manage/tools/feature-workbench` to inspect the shared `CatalogItem`
and integration response-contract fixtures. The workbench uses the application Mantine theme and the same segmented
fixture controls as the custom-widget preview. It covers ready, setup-required, standalone, connection-error, selected,
loading, disabled, parser-success, and parser-failure states without requiring a live service.
Use the workbench after changing shared catalog presentation or generated parser contracts, then run the focused test:
```sh
pnpm exec vitest run "apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.spec.tsx"
```
The workbench is deliberately development-only and is not linked from management navigation. It does not yet exercise
real integration branding, long translated labels, right-to-left text, high-contrast screenshots, touch interaction,
small viewport clipping, live network latency, retry transitions, or service-specific response schemas. Those remain
manual review items until screenshot and browser-automation coverage is added.
### Check one native feature
Run `pnpm check:feature <kind>` before submitting an integration or widget change. The command resolves the kind through
`integrationDefs`, `widgetKinds`, the widget manifest, creator registry, documentation slug maps, and capability
descriptor. When an integration and widget share a kind, such as `wud`, both sides are checked together.
```sh
pnpm check:feature wud --plan
pnpm check:feature wud
```
`--plan` (or `--dry-run`) prints the deterministic affected files and commands without executing them. The normal mode
runs feature contracts, nearby fixture and unit tests, affected package typechecks, and lint and formatting for only the
resolved files. Unknown kinds fail before any command runs. Live-service, container, and visual checks are printed as
optional and are never executed implicitly because they may require credentials, images, or a running development app.
### Useful commands
- `pnpm dev` — Next.js on port 3000.
@@ -140,8 +76,6 @@ optional and are never executed implicitly because they may require credentials,
- `pnpm db:seed` — seed the default database data explicitly.
- `pnpm db:studio` — inspect the database.
- `pnpm format` / `pnpm format:fix` — check or apply oxfmt.
- `pnpm generate:feature` — scaffold a native integration, widget, or paired feature.
- `pnpm check:feature-contracts` — validate native feature registries, docs, translations, support maps, and exports.
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
- `pnpm typecheck` — typecheck all workspaces.
- `pnpm test` — unit and contract suites.
@@ -58,12 +58,11 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker
| Environment Variable | Description | Possible values | Default |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - |
| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
| Environment Variable | Description | Possible values | Default |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
## Database
@@ -6,10 +6,18 @@ tags:
- Configuration
---
import imageStartFromScratchStepServerSettings from "./img/onboarding-from-scratch-step-server-settings.png";
import imageStartFromScratchStepAdminUser from "./img/onboarding-from-scratch-step-admin-user.png";
import imageStartFromScratchStepExternalAdminGroup from "./img/onboarding-from-scratch-step-external-admin-group.png";
import imageStartFromScratchStepFinish from "./img/onboarding-from-scratch-step-finish.png";
import manageBoardHeaderEditMode from "./img/manage-board-header-edit-mode.png";
import manageBoardHeaderAddItem from "./img/manage-board-header-add-item-menu.png";
import manageBoardHeaderChooseItem from "./img/manage-board-header-choose-item.png";
import manageBoardNoApp from "./img/manage-board-no-app.png";
import manageExportOldHomarr from "./img/old-homarr-export-data.png";
import manageImportOldHomarrDropzone from "./img/old-homarr-import-dropzone.png";
import manageImportOldHomarrChooseData from "./img/old-homarr-import-choose-imported-data.png";
import manageImportDecryptionToken from "./img/old-homarr-import-decryption-token.png";
:::tip
@@ -21,50 +29,117 @@ Congratulations on installing Homarr. Now it's time to get yourself familiar wit
## Onboarding
Onboarding appears the first time Homarr starts. Its goal is to create a useful responsive board, not merely an
administrator account. The setup uses one guided studio, so you can keep the safe defaults and finish quickly or review
every detected service before anything is added to the board.
The onboard will show the first time you start Homarr.
This process will help you with your first steps and adding some initial data, such as the administrator account.
:::note
If you see this screen repeatedly after restarting the Homarr container, your Docker mounting paths may be missing,
If you see this screen repeatitly after you restarted the Homarr container, your Docker mounting paths may be missing,
incorrect or read-only. Read [our installation instructions](/docs/getting-started/installation/docker) again to fix this issue.
:::
### Restore or start fresh
You can either import a previous dashboard using the ZIP archive or start from scratch:
SQLite installations can restore a complete Homarr database backup from the welcome screen. This restores current
boards, people, apps, integrations, and settings. The pre-1.0 ZIP importer is no longer included.
![Screenshot of Step 1 onboarding](./img/onboarding-step-1.png)
For a new installation, Homarr first creates either a credentials administrator or an administrator group matching your
LDAP/OIDC provider. The browser that starts setup claims the first-run session; refreshes are safe, while another browser
cannot change credentials or integrations during that setup.
You may refresh anytime and continue from a different device - the onboarding is saved immediately on the server for your convenience.
### Setup studio
### Starting from scratch
The studio keeps related choices together:
Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form.
If the credentials provider was selected, you'll be prompted to create an admin user for Homarr.
This user cannot be deleted and has full control over Homarr and all of its boards.
You must choose a username and password (both can later be changed).
We enforce strict password requirements when you enter your password to protect your instance if you accidentally
expose it to the internet.
1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, domain, or reverse-proxy path used by your services. URL suggestions can use separate ports, subdomains, or one path per service.
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
<img src={imageStartFromScratchStepAdminUser} alt={"Screenshot form to create admin user"} width={300} />
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
is unavailable, which is useful for remote Docker, rootless Docker, Synology, and mixed deployments. On Kubernetes or
bare-metal installations without a Docker socket, enter services manually and use the same integration catalog.
If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group.
This group will be used to synchronize users from your provider to Homarr.
When a user logs in through the provider, any of their groups that match groups in Homarr will be assigned to the user.
The name of the group must match exactly the name of the group in the provider.
After setup, **Open my board** takes you directly to the configured board. The in-product tour then introduces edit mode,
responsive layouts, widgets, and search without delaying completion.
<img
src={imageStartFromScratchStepExternalAdminGroup}
alt={"Screenshot form to create external admin group"}
width={300}
/>
## Creating another board
Next, you can configure some basic settings for Homarr. We recommend to leave all settings at default for most users.
Here you can also disable analytics. Please note that our analytics are completly anonymous and is fully compliant with GDPR and PECR.
<img
src={imageStartFromScratchStepServerSettings}
alt={"Screenshot of analytics and indexing settings in the onboarding"}
width={300}
/>
### Setting up integrations
After configuring server settings, the onboarding guides you through setting up your integrations.
#### Docker service discovery
If Docker is available (socket mounted), Homarr automatically detects running containers and matches them to known integration types.
Matching integrations are pre-selected in the integration list, saving you from manually picking each one.
#### URL templating
You can define a base URL template to auto-fill integration URLs in bulk.
Two modes are supported:
- **Subdomain mode** - e.g. `{app}.example.com` where `{app}` is replaced with the integration name
- **Port mode** - e.g. `192.168.1.1:{port}` where `{port}` is replaced with the integration's default port
#### API key helper links
For integrations that require API keys (such as Sonarr, Radarr, SABnzbd, or Seerr), a helper link opens the relevant settings page in the target application so you can quickly copy your key.
#### Creating apps from Docker containers
You can optionally auto-create bookmark apps for all discovered Docker containers, making them immediately available on your board.
:::note
Release providers and Search.ch are configured directly in their widgets instead of the integrations page.
:::
After this step, you finished the onboarding.
The onboarding can no longer be started again.
From here you can click on one of the provided links to continue your journey.
We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board).
<img src={imageStartFromScratchStepFinish} alt={"Screenshot of the finish onboarding process with links"} width={300} />
### Importing a ZIP from version before 1.0.0
This is recommended when you used Homarr before and want to re-import your data.
It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0).
<img src={manageExportOldHomarr} alt="Export from old Homarr" style={{ maxWidth: 500 }} />
**How to export and import data**
1. Navigate to your old Homarr, open your management pages, open the tools submenu and open `Migrate to 1.0`.
2. Check the contents that you want to export. We recommend you to export all data.
3. Click on the export data button
4. Save the ZIP file, that is being downloaded, on your device. If you selected "integrations" or "users", you also get an encryption / decryption key.
Copy it and do not loose it!
5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose `Import from Homarr before 1.0`
6. Drag and drop your previously downloaded ZIP archive into the file upload: <br/><img src={manageImportOldHomarrDropzone} style={{ maxWidth: 500 }} />
7. Next, choose the data that you want to import and how to import it. Since screen sizes were removed in 1.0, we create a new board for every screen size by default. We recommend you to only keep the best layout and adjust it later in 1.0.0 - but you can also import all of them and pick later.<br/><img src={manageImportOldHomarrChooseData} style={{ maxWidth: 500 }} />
8. If you previously exported integrations or users, you will be asked to decrypt the data safely using your obtained encryption token. Paste into the modal and confirm your action.<br/><img src={manageImportDecryptionToken} style={{ maxWidth: 500 }} />
9. Next, you can [follow the same setup as when starting from scratch](#starting-from-scratch). After you completed the onboarding, all of your data will be migrated and available.
## Creating your first board
In Homarr, you organize your apps and widgets on boards.
Boards use fixed-size tiles so the same widget keeps the same proportions on every screen.
Drag, resize, and section controls are available in edit mode.
Onboarding already created the first board. To create another one, navigate to `Manage` > `Boards` > `New board`:
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
![](./img/manage-create-board-button.png)
A board must always have a name and can either be public or private.
@@ -1,95 +0,0 @@
---
title: "Docker labels"
description: "Discover apps, integrations, and widgets from container labels during Homarr onboarding."
---
# Docker label discovery
During onboarding, Homarr scans running containers on every reachable
[Docker-compatible host](/docs/integrations/docker). Labels let you provide exact app metadata instead of relying on
image-name matching. You can review and deselect every result before Homarr creates anything.
A container with valid Homarr labels takes priority over image-based discovery. Containers without valid labels can
still be suggested from their image and published ports.
## Homarr labels
`homarr.name` and `homarr.href` are required for label discovery. All other labels are optional.
| Label | Purpose |
| -------------------- | -------------------------------------------------------------------------- |
| `homarr.name` | App or integration display name |
| `homarr.href` | Address opened by the app and used as the suggested integration address |
| `homarr.group` | Container section that receives the selected app and compatible widget |
| `homarr.icon` | App icon URL |
| `homarr.description` | App description |
| `homarr.ping` | Address used for app status checks |
| `homarr.id` | Stable source identifier; defaults to the Docker container ID |
| `homarr.board` | Intended board name; see [Board targeting](#board-targeting) |
| `homarr.integration` | Homarr integration kind, for example `sonarr` |
| `homarr.widget` | Homarr widget kind to add when it supports the selected integration |
| `homarr.hide` | Excludes the container when this label is present, regardless of its value |
Unknown integration and widget kinds are ignored. Do not put API keys, passwords, or other secrets in labels. Enter
credentials in onboarding, where Homarr stores them using its normal encrypted integration-secret flow.
## Compose example
```yaml
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
labels:
homarr.name: "Sonarr"
homarr.href: "http://server.local:8989"
homarr.group: "Media"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg"
homarr.description: "TV library"
homarr.ping: "http://server.local:8989"
homarr.integration: "sonarr"
homarr.widget: "calendar"
```
Use an address that is reachable where it is needed. A Docker service name can work for server-side integration
requests, while an app link opened in a browser usually needs a LAN hostname, IP address, or reverse-proxy address.
Onboarding accepts these self-hosted address formats directly and only requires the field to be non-empty.
## Homepage label fallback
When `homarr.name` is absent, onboarding also understands these Homepage labels:
- `homepage.name`
- `homepage.href`
- `homepage.group`
- `homepage.icon`
- `homepage.description`
`homepage.name` and `homepage.href` are required for the fallback. Homarr does not combine partial Homarr metadata with
Homepage metadata: once `homarr.name` is present, use `homarr.*` labels for the remaining fields too.
## What onboarding creates
In **Connect**, label-discovered apps and integrations appear before image-based suggestions. You can import multiple
services in one pass and edit every suggested address. For image-matched apps, Homarr uses the detected service type
when building host, subdomain, or reverse-proxy path suggestions.
When setup completes, Homarr rechecks the selected container sources and then:
- creates an app with its name, address, icon, description, and ping address;
- creates the requested integration and links its app after you provide required credentials;
- places grouped apps in a board container named by `homarr.group`;
- places ungrouped apps in the main board area;
- adds `homarr.widget` only when the widget kind is valid and compatible with the selected integration;
- keeps image-based discovery available for unlabeled containers.
If a selected container disappears, targets another board, or requests an incompatible widget, setup finishes with a
warning instead of creating an invalid board item.
## Board targeting
On a fresh installation with one seeded `dashboard` board, one unique selected `homarr.board` value can name that first
board. Otherwise, the label must match the board selected in onboarding. Services labeled for another board are skipped
and reported in the completion warning.
Onboarding configures one board at a time. Run normal board management after setup if you want to distribute services
across several boards.
+3 -73
View File
@@ -122,77 +122,9 @@ services:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
```yaml
services:
homarr:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./docker-ca.pem:/run/secrets/docker-ca.pem:ro
environment:
DOCKER_ENDPOINTS: >-
[
{
"id": "local",
"name": "Local Docker",
"kind": "docker",
"transport": { "type": "socket", "path": "/var/run/docker.sock" },
"capabilities": ["inventory", "logs", "lifecycle", "remove"]
},
{
"id": "remote-read-only",
"name": "Production inventory",
"kind": "docker",
"transport": {
"type": "tls",
"host": "docker.example.com",
"port": 2376,
"caPath": "/run/secrets/docker-ca.pem"
},
"capabilities": ["inventory", "logs"]
}
]
```
The stable `id` keeps containers from different endpoints distinct. `name` is shown in the interface, and `kind` can be `docker` or `podman`. Every endpoint must include the `inventory` capability; optionally add `logs`, `lifecycle`, and `remove`. Homarr hides or disables actions the endpoint does not allow and also enforces the same capabilities on the server.
Supported transports are:
- `socket`: an absolute in-container `path`
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
:::warning[Plaintext TCP]
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`.
:::
When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables.
### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system.
The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
Homarr can show a service as:
- **Ready to connect** when its image or name matches a supported integration
- **New service** when it can be added as an app but no integration is recognized
- **Already represented** or **Linked** when an app or integration already matches
- **Address changed** when a service appears to match an integration but its reachable address has changed
Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing.
:::info[Review before changing]
Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
:::
Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy.
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
Each container row has an action menu with the following options:
- **View logs** - opens a dedicated logs page with a live terminal
@@ -228,13 +160,11 @@ They are then available in the app list and can be used as normally added apps.
![Apps added from containers](img/add-to-homarr-apps.png)
### Container labels
### Configuration
`homarr.hide` excludes a container from the Docker Containers widget, tools page, and onboarding discovery whenever the
label is present.
Currently Homarr supports one label for configuration of the containers:
During first-run onboarding, Homarr can also discover apps, integrations, groups, and compatible widgets from
`homarr.*` labels, with Homepage labels as a fallback. See the complete [Docker label reference](/docs/integrations/docker-labels).
- **homarr.hide:** If set to any value, the container will be hidden from the Docker Containers widget and tools page.
### Security
@@ -14,8 +14,6 @@ import { kubernetesIntegration } from ".";
Homarr provides a user-friendly Kubernetes dashboard that offers an overview of your cluster’s resources in a clear and structured interface. It allows users to view key Kubernetes objects, including `Pods`, `Services`, `Ingresses`, `Nodes`, `ConfigMaps`, `Secrets`, `Namespaces`, and `Volumes`, as well as basic metrics when the Kubernetes Metrics Server is enabled.
Docker service discovery does not convert Kubernetes resources into Homarr apps or integrations. Kubernetes remains a separate, read-only operational view, so a cluster resource is never changed or linked automatically.
:::warning[Read only]
This integration is read-only, meaning Homarr does not modify or manage cluster resources but serves as an informational tool to enhance visibility into your Kubernetes environment.
:::
@@ -27,7 +25,7 @@ This integration is read-only, meaning Homarr does not modify or manage cluster
Before deploying Homarr in Kubernetes, ensure the following prerequisites are met:
- Kubernetes (Version >= 1.24.0)
- Metrics Server ([install from here](https://github.com/kubernetes-sigs/metrics-server)) is optional. Without it, Homarr still shows cluster inventory, node status, and reserved capacity, and identifies live CPU and memory usage as unavailable.
- Metrics Server ([Install from here](https://github.com/kubernetes-sigs/metrics-server))
- RBAC Enabled (Required for Homarr Helm chart)
### Configuring RBAC in Helm Chart
@@ -47,25 +45,6 @@ Enabling RBAC in the Helm chart creates the necessary Kubernetes Role-Based Acce
- ClusterRole & ClusterRoleBinding: Extends the same permissions cluster-wide for additional flexibility.
:::
### Multiple Kubernetes contexts
When Homarr runs outside a cluster, set `KUBECONFIG` explicitly so Homarr loads the contexts from the referenced kubeconfig file or files. Mount the kubeconfig read-only and set the standard `KUBECONFIG` environment variable to its path inside the Homarr container:
```yaml
services:
homarr:
volumes:
- ./kubeconfig:/app/config/kubeconfig:ro
environment:
KUBECONFIG: /app/config/kubeconfig
```
The Kubernetes management pages include a context selector. The selected context is stored in the page URL, so each React Query cache entry and every API request remains context-specific. Resources with the same namespace and name in two contexts therefore remain separate.
Context status is checked independently. If one context is unavailable, the selector still lists healthy contexts. A context is shown as **Metrics unavailable** when its Kubernetes API is reachable but Metrics Server is not; inventory remains available without live CPU and memory usage.
When Homarr runs inside Kubernetes without `KUBECONFIG`, it keeps using the in-cluster service account as the single default context.
### Homarr Dashboard Overview
Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes:
-24
View File
@@ -86,30 +86,6 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom`
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
## Docker action payload migration
Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a
breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action.
Replace the old payload:
```json
{ "ids": ["container-id"] }
```
with endpoint-qualified targets:
```json
{
"targets": [{ "endpointId": "local", "id": "container-id" }]
}
```
Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action.
Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
Docker or Podman endpoints from targeting the wrong service.
## Partial integration failures
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
+2 -2
View File
@@ -51,6 +51,6 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
## List of apps
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
![Apps list](./img/app-list.png)
+52 -35
View File
@@ -8,61 +8,78 @@ tags:
# Assistant
Homarr Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
approval before making changes.
Homarr Assistant can answer questions about your Homarr instance and use Homarr tools on your behalf. It follows the
permissions of the signed-in user and asks for approval before it changes anything.
![Homarr Assistant conversation with suggested prompts](/img/assistant/conversation.png)
## Set it up
## Open the assistant
- Select **Assistant** in the user menu.
- Press `Shift + A` from anywhere in Homarr.
- Type a question in `Cmd/Ctrl + K`, then select **Ask Homarr Assistant**.
- Add the **Assistant** widget to a board for a permanent conversation view.
You can minimize a conversation while it works. The activity bar shows its progress and the user menu shows unread
responses.
## What it can do
- Find and update boards, apps, integrations, widgets, and dashboard settings.
- Check downloads, media, Docker containers, service health, and other connected integrations.
- Create formatted notes and Custom Widgets.
- Search the web when OpenRouter web search is enabled.
- Show tool activity, reasoning summaries, usage, time, and cost when the provider reports them.
Use `@` to add an app, board, integration, widget, or tool as context. Use `/` for common prompts. You can also paste or
attach images and text files when the selected model supports them.
## Configure the assistant
Open **Management → Assistant**.
![Assistant provider, credentials, and model configuration](/img/assistant/configuration.png)
1. Select a provider and check its API URL.
2. Enter an API key when required, then save the connection.
3. Refresh the model list and select a model.
4. Enable the assistant and save.
1. Select a provider.
2. Check the API base URL and add an API key if required.
3. Select **Save provider connection**.
4. Refresh the model list and choose a model.
5. Enable the assistant, then select **Save assistant**.
Choose a provider preset or use **Custom endpoint** with an OpenAI-compatible Chat Completions API. The
[Homarr provider](../workshop/homarr-provider) is the default and needs no administrator API key. The Homarr team pays
for a limited number of Assistant uses for each user every day; users sign in to the Community Workshop to use them.
Homarr includes presets for OpenRouter, OpenAI, Claude, Gemini, xAI, Groq, Mistral, DeepSeek, Together AI, Ollama, and
LM Studio. Use **Custom endpoint** for another OpenAI-compatible Chat Completions service.
The [Homarr provider](../workshop/homarr-provider) does not require an administrator API key. Each user signs in to the
Community Workshop and receives a separate daily allowance. A shared Workshop daily quota also applies, so requests
can be rejected after that shared quota is exhausted.
:::info Local model servers
Inside a container, `localhost` means the Homarr container. Use the model server's container name, local DNS name, or
host gateway address.
host gateway address instead.
:::
## Use it
## Approvals and data
- Select **Assistant** in the user menu or press `Shift + A`.
- Ask from `Cmd/Ctrl + K`.
- Add the [Assistant widget](../widgets/assistant) to a board.
Read-only tools can run automatically. Changes show an approval card with the exact action before Homarr runs it.
Normal Homarr permissions still apply.
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
API keys and custom headers are encrypted in the Homarr database and are never returned to the browser. Prompts,
attachments, tool definitions, and tool results are sent to the configured model provider. Check that provider's data
policy before enabling the assistant.
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
summary of accessible Homarr resources. Live status is fetched only when needed.
Conversations are stored per user. Users can edit or retry messages, copy responses, export a conversation as Markdown,
and delete conversations.
## Permissions and privacy
- Read-only actions can run automatically. Changes require approval.
- Homarr permissions apply to every tool call and mentioned item.
- API keys and custom headers are encrypted and never returned to the browser.
- Conversations are stored per user and can be exported or deleted.
Prompts, attachments, tool definitions, and tool results are sent to the selected model provider. Review that
provider's privacy policy before enabling the assistant.
## Troubleshooting
## If setup does not work
- **No models found:** check the base URL, model discovery path, and API key. You can enter a model ID manually.
- **Assistant is unavailable:** enable it and save the configuration.
- **Tool calls stop:** select a model that supports streaming and tool calls.
- **Local endpoint is unreachable:** check container networking and the server's listen address.
- **The assistant is unavailable:** enable it and save the configuration.
- **A model stops during a tool call:** use a model that supports streaming and structured tool calls.
- **A local endpoint cannot be reached:** check container networking and the endpoint's listen address.
See [MCP](./mcp) for external AI clients and [Custom Widget agent authoring](./custom-widgets/agent-authoring) for the
widget authoring workflow.
Homarr exposes its MCP-enabled procedures to external clients through the [MCP server](./mcp). MCP omits
`customWidget_*` tools for non-admin API keys, while the built-in assistant adds browser actions and optional
OpenRouter web search. Custom Widget authors can find the full workflow in
[Agent authoring](./custom-widgets/agent-authoring).
-3
View File
@@ -53,9 +53,6 @@ Before restoring, Homarr uses **sql.js WASM** to analyze the backup directly in
Type `I understand` to acknowledge that restoring will replace your current database. This cannot be undone.
During first-run onboarding, the database is still empty, so the restore preview goes directly to **Restore Database**
without the extra typed confirmation. After Homarr restarts, onboarding opens the restored default home board.
### 4. Restore
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
+2 -13
View File
@@ -20,8 +20,6 @@ You can create as many boards as you want.
Boards can be restricted to user groups.
A user may also have a "home board" which they will see at the root path (e.g. my-homarr.com or my-homarr.com/board).
Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) or via the management pages.
The management page includes a compact structural preview of each board's Base layout so you can recognize it before
opening it. Containers include their visible widgets and app icons instead of appearing empty.
Names of boards must be unique because this will be used in the URL to load the desired board.
@@ -109,7 +107,7 @@ The complete canvas always fits the available width and never adds horizontal bo
- **Mobile** starts at `0px` and uses three columns on new boards.
- **Base** is the widest layout and starts at `768px` on new boards.
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between their breakpoints.
Mobile and Base can be renamed and their column counts and rails can be changed, but they cannot be removed. You can add removable custom layouts between their breakpoints.
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in Base or any custom layout. New custom layouts are generated from Base and scaled to their selected column count.
@@ -130,7 +128,7 @@ The total number of columns in the layout. Changing it projects the saved positi
#### Fixed rails
Base and custom responsive layouts can enable a fixed left rail, a fixed right rail, or both. Mobile has no fixed rails.
Each responsive layout can enable a fixed left rail, a fixed right rail, or both.
Set a rail width to zero to disable it for that layout.
Choose a width from one to three columns with the three-step slider.
The preview shows how much space remains for the main canvas.
@@ -159,15 +157,6 @@ The visible corner controls provide a 44-pixel resize target, and edge controls
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
Exit edit mode to save the resulting positions.
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
destinations before the widget is created, so the common flow does not require a second move step.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
saved integration changes apply everywhere that connection is used.
Pointer users can drag anywhere on a widget tile. Containers use the grab button at their top-left corner.
Resize tiles from any edge or corner handle.
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
@@ -13,9 +13,9 @@ Check [the available integrations](/docs/category/integrations) for further info
## Create an integration
To create an integration, choose **New integration** and select the supported application from the searchable grid. The grid groups related services visually and shows which widgets each integration supports.
To create an integration, you can choose the supported application from the dropdown at the top right:
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
![](./img/create-integration-dropdown.png)
Next, you'll need to fill out the required fields to configure the integration:
@@ -59,22 +59,6 @@ It is not allowed to create an integration without passing the test.
This is to ensure that you minimize the amount of broken applications on your Homarr instance.
The test and create button will perform a network request to the application and authenticate if required.
When you connect a service from a board, Homarr keeps you in the same setup flow. After the connection succeeds, it
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
widget returns to its settings with the new integration already selected.
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
changes apply to every app and widget that uses the integration.
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
create one.
When the connection unlocks useful widgets that are not on the current board yet, the confirmation also shows up to
three explainable recommendations. A recommendation is based only on the services you can use and the widget kinds
already present on that board. You can open the existing widget picker prefiltered to the recommendation, or dismiss
recommendations for that service on that board.
## Managing permissions
On the edit page of an integration, you can manage which users / groups can
@@ -8,7 +8,8 @@ tags:
# Search Engines
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`.
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
![](./img/search-engines.png)
You can add any search engine that has a search URL that accepts a search term as a parameter.
@@ -17,8 +17,6 @@ import { anchorIntegration } from "@site/docs/integrations/anchor";
The Anchor Note widget shows a selected note from your Anchor integration. If your note permissions allow it, you can also edit the note title and content directly in Homarr.
Advanced view always shows the note title and update time alongside the complete formatted document, permission, creation date, and pinned or archived state. Compact title and update-time switches do not hide that information in advanced view.
### Screenshots
import anchorNoteWidgetScreenshot from "./img/widget.png";
@@ -14,7 +14,7 @@ import { archiveTeamWarriorWidget } from ".";
<WidgetHeader widget={archiveTeamWarriorWidget} categories={["Archiving", "Monitoring"]} />
Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and optional broadcast message visible. Advanced view adds the complete active-job list, job start times, project and bandwidth-session identifiers, session transfer totals, exact update time, and the full broadcast message whenever one is available. A failed refresh keeps the last snapshot visible with a stale-data warning.
Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and broadcast message visible. Advanced view adds the complete active-job list and exact update time.
### Supported integration
+1 -3
View File
@@ -36,9 +36,7 @@ import widgetOverview from "./img/widget-overview.png";
]}
/>
Advanced view shows every missing-subtitle and health summary regardless of compact visibility options, together with the already-fetched Sonarr and Radarr SignalR states and announcement count.
<WidgetInteractionGuide advancedView />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -34,7 +34,7 @@ This widget displays your Beszel alert configurations separated by status (trigg
]}
/>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
@@ -4,8 +4,6 @@ description: "Card grid view of all Beszel-monitored systems with real-time metr
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -36,8 +34,6 @@ Select a system card to open the complete statistics view, including system, fil
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -4,8 +4,6 @@ description: "Table view of all Beszel-monitored systems with sortable columns a
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -37,8 +35,6 @@ Select a system row to open the complete statistics view.
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -16,7 +16,6 @@ import { bookmarksWidget } from ".";
The bookmarks widget allows you to display multiple app links in a single widget.
You can add links to your favorite applications, websites, or any other resources you want quick access to.
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
### Screenshots
+1 -1
View File
@@ -74,7 +74,7 @@ import withoutIntegrations from "./img/without-integrations.png";
### Interactions
- Hover over a marked date to preview its events in a popover.
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
- The advanced view replaces the month grid with a grouped event list. Use its previous and next controls to change months.
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
<WidgetInteractionGuide />
+1 -1
View File
@@ -27,7 +27,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
</div>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
+1 -5
View File
@@ -27,11 +27,7 @@ The Coolify widget displays a compact overview of your Coolify instance. It can
]}
/>
Advanced view opens the Servers, Applications, and Services sections regardless of their compact visibility switches. It identifies each Coolify integration, shows server `is_reachable` and `is_usable` values, and lists applications nested in services when the API provides them. Healthy integrations remain visible if another source fails, with the failed source identified by a warning.
IP addresses remain masked in advanced view until you explicitly use the eye control. Public resource, Coolify dashboard, and log links retain the widget's safe new-tab handling.
<WidgetInteractionGuide advancedView />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -20,8 +20,6 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
<WidgetHeader widget={dnsHoleControlsWidget} categories={["DNS Hole"]} />
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget&apos;s width and height.
### Screenshots
@@ -17,8 +17,6 @@ import { dnsHoleSummaryWidget } from ".";
<WidgetHeader widget={dnsHoleSummaryWidget} categories={["DNS Hole"]} />
When several DNS integrations are selected, Homarr keeps healthy sources visible if one source fails and shows a warning identifying the unavailable integration.
This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. When the widget has enough room, it adds a scrollable status row naming each selected source.
### Screenshots
@@ -23,8 +23,6 @@ import { glancesIntegration } from "@site/docs/integrations/glances";
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
Advanced view is a complete health workspace: it shows every available CPU, temperature, memory, GPU, filesystem, update, reboot, and system-information section even when compact visibility switches hide some of them on the board. Proxmox clusters likewise show uptime, CPU, memory, and every node, VM, container, and storage section. Storage-volume filters, units, and permissions remain in effect.
### Screenshots
import systemView from "./img/system-view.png";
@@ -33,7 +33,7 @@ import screen from "./img/screen.png";
<WidgetInteractionGuide />
Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view displays the complete photo without cropping, groups previous, pause or resume, and next together, and includes every loaded photo in its thumbnail strip. If an Immich refresh fails, the last loaded album stays visible with a stale-data warning.
Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view displays the complete photo without cropping, groups previous, pause or resume, and next together, and includes every loaded photo in its thumbnail strip.
### Adding the widget
@@ -18,8 +18,6 @@ import { sonarrIntegration } from "@site/docs/integrations/sonarr";
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
Advanced view always shows both Missing and Queued panels side by side when space allows, requests at least 50 items per source, exposes queue status and time remaining, and keeps partial-integration warnings visible. Compact tab visibility and page-size choices continue to shape the board tile.
### Screenshots
import widgetMissing from "./img/missing.png";
@@ -19,10 +19,6 @@ import { plexIntegration } from "@site/docs/integrations/plex";
Provides a list of newly added media or upcoming releases from your media server.
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
Advanced view displays every release in a roomy poster layout with its complete available metadata, description, tags, and visible source name, regardless of the compact layout and visibility options.
### Screenshots
import backdrop from "./img/backdrop.png";
@@ -24,7 +24,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
Sessions without user metadata remain visible and are labeled as an unknown user.
@@ -20,7 +20,6 @@ import { tdarrIntegration } from "@site/docs/integrations/tdarr";
The media transcoding widget provides you with an overview of your media transcoding status.
It shows you the current queue, worker status, and statistics about your transcoding jobs.
Advanced view presents all three panels together. If refreshes fail after data has loaded, Homarr keeps the last useful result visible and marks it as stale.
### Screenshots
@@ -20,8 +20,6 @@ import { networkControllerStatusWidget } from ".";
Shows connected users and connected guest devices on wired or wireless networks.
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
### Screenshots
import wifiCard from "./img/wifi.png";
@@ -36,9 +36,7 @@ import summaryCard from "./img/summary.png";
]}
/>
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -34,9 +34,7 @@ import notificationsScreenshot from "./img/notifications.png";
items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]}
/>
Advanced view always shows available notification logos and sources and removes the compact body line limit. External notification links retain the same safe new-tab behavior.
<WidgetInteractionGuide advancedView />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -5
View File
@@ -72,10 +72,6 @@ The widget adapts its layout based on the available space:
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
### Advanced view
Advanced view is a complete PatchMon posture view. It always shows the compliance hero, all eight host and package statistics, the full OS distribution with versions, and the last-updated time. Compact visibility switches and OS limits still shape the board tile, but do not hide already-fetched security information in advanced view. Threshold and chart-style choices remain in effect.
### Supported Integrations
<WidgetIntegrations
@@ -87,7 +83,7 @@ Advanced view is a complete PatchMon posture view. It always shows the complianc
]}
/>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -3
View File
@@ -35,9 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -3
View File
@@ -21,9 +21,7 @@ import screenshot from "./img/screenshot.png";
<img src={screenshot} alt="rss-feed noted-lol" width={128 * 4} />
Advanced view keeps the configured feeds and post limit unchanged while showing each fetched post's full description, image, source, relative time, and exact publication timestamp. Right-to-left text and safe external links continue to follow the widget configuration.
<WidgetInteractionGuide advancedView />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -22,8 +22,6 @@ The entity state enables you to display the current state of an entity.
This works with most entities and enables you display almost any data.
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
Advanced view shows the safe Home Assistant metadata owned by Homarr: the friendly name, entity ID, unit, device class, icon, last-changed time, and last-updated time. Arbitrary attributes remain intentionally excluded.
### Screenshots
import sun from "./img/sun.png";
@@ -20,8 +20,6 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
The execute automation widget allows you to trigger an automation with one click.
Advanced view shows the automation ID, interaction permission, last successful run in the current browser session, and the latest action error together. Session-only history is not presented as Home Assistant history.
### Screenshots
import empty from "./img/empty.png";
@@ -18,10 +18,6 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Screenshots
import screenshot from "./img/screenshot.png";
+1 -3
View File
@@ -16,8 +16,6 @@ import { stockPriceWidget } from ".";
Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard.
The widget prioritizes the symbol, current price, and trend. Company name, change, time range, and a taller chart appear progressively when both widget dimensions can support them.
In advanced view, the widget always shows the returned symbol and company name, configured range and interval, current and previous close, absolute and percentage change, range minimum and maximum, and the full price-history chart. These values use the same configured Yahoo Finance query as the compact widget; advanced view does not change the symbol, range, or interval.
### Screenshots
import apple from "./img/apple.png";
@@ -36,7 +34,7 @@ import nvidia from "./img/nvidia.png";
<img src={nvidia} alt="stock-price nvidia" width={128 * 2 + 2} height={128 * 2 + 4} />
</div>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -44,9 +44,7 @@ This widget displays filesystem and SMART disk usage for supported server and NA
]}
/>
Advanced view keeps your configured volume filters while showing each integration name, usage percentage, used and total space, free-space percentage, SMART status, available temperature, and the usage background bar regardless of compact display options.
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -17,8 +17,6 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Sections
| Section | Description |
-4
View File
@@ -17,10 +17,6 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Supported Integrations
<WidgetIntegrations
-4
View File
@@ -19,10 +19,6 @@ This widget displays the status of the UPS devices reported by a Network UPS Too
status (online, on battery, charging or low battery), battery charge and estimated runtime, output load, and input and
output voltage.
Every device keeps an integration-scoped identity. Advanced view always labels each device with its owning integration, while compact layouts add the source label only when multiple healthy integrations are present.
When multiple UPS integrations are selected, devices from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Layouts
import screenshotFull from "./img/screenshot.png";
@@ -17,10 +17,6 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
When multiple Uptime Kuma integrations are selected, monitors from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Supported Integrations
<WidgetIntegrations
@@ -8,7 +8,6 @@ import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { wudWidget } from ".";
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
@@ -27,10 +26,6 @@ This widget shows two stats from your WUD instance: the total number of monitore
]}
/>
<WidgetInteractionGuide />
In advanced view, the widget always shows its title, update ring, monitored-container summary, and the full list of available container updates. The configured horizontal or vertical layout still applies. Visibility options only affect the compact board view. A failed refresh keeps the last WUD snapshot visible with a stale-data warning.
### Adding the widget
<AddingWidget />
+17 -14
View File
@@ -12,8 +12,9 @@ import { OpenCollectiveLink } from "@site/src/components/assistant-provider/open
# Homarr provider
The Homarr provider gives Community Workshop users access to the **Homarr model** without an administrator API key.
The Homarr team chooses the upstream model based on quality, reliability, and cost.
The Homarr provider gives Workshop users access to the **Homarr model** without an administrator API key. The Homarr
team selects a capable model based on quality, reliability, and cost, and can change the upstream model without
changing the user-facing provider. Each user signs in with GitHub and receives a separate daily allowance.
## Set it up
@@ -25,25 +26,27 @@ The Homarr team chooses the upstream model based on quality, reliability, and co
## Daily allowance
- Each user gets **50 request units per UTC day**.
- Each model request uses one unit. A tool loop can use several units.
- Forwarded requests count even if the model later fails.
- Allowances reset at 00:00 UTC. A shared daily limit also applies.
- Each user starts with **50 request units per UTC day**.
- Each model request uses one unit. Agent tool loops can make multiple model requests.
- Once Workshop forwards a request, it uses one unit even if the upstream later fails. This prevents failed requests
from bypassing the shared cost and abuse limits.
- The allowance resets at 00:00 UTC.
The battery beside the model selector shows the remaining allowance and reset time. Workshop enforces all limits on
its server; changing the browser clock cannot bypass them.
The battery beside the model selector shows the remaining allowance and reset time. The Workshop server enforces the
limit before it sends a request to OpenRouter.
## Privacy
## Data and cost
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
The Workshop keeps the OpenRouter API key on its server. It does not send that key to Homarr or the browser.
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
Prompts, attachments, tool definitions, and tool results are sent through OpenRouter to generate a response. The
Workshop proxy does not retain that content, responses, or per-request telemetry. It stores only the Workshop user,
UTC day, and counters required to enforce per-user and global daily allowances. Requests require OpenRouter routing
with zero data retention and data collection disabled. Homarr does not use this content to train language models.
We would rather become millionaires in a more ethical manner.
The community pays for these requests. Donations help cover the cost.
The Homarr community pays for these requests. Donations help cover the cost.
<OpenCollectiveLink />
@@ -1,147 +1,84 @@
import React, { useRef } from "react";
import styles from "../../../../pages/index.module.css";
import { supportedIntegrations } from "../../../../constants/supported-integrations";
import LeaderLine, { SocketType } from "leader-line-new";
const nodePositions = [
styles.dataflowNodeTopLeft,
styles.dataflowNodeMiddleLeft,
styles.dataflowNodeBottomLeft,
styles.dataflowNodeTopRight,
styles.dataflowNodeMiddleRight,
styles.dataflowNodeBottomRight,
];
const cyclingIntegrations = supportedIntegrations.filter((integration) => integration.name !== "Homarr");
export const DataflowVisualizationComponent = () => {
const homarrRef = useRef<HTMLImageElement>(null);
const sonarrRef = useRef<HTMLImageElement>(null);
const lidarrRef = useRef<HTMLImageElement>(null);
const radarrRef = useRef<HTMLImageElement>(null);
const jellyfinRef = useRef<HTMLImageElement>(null);
const plexRef = useRef<HTMLImageElement>(null);
const sabnzbdRef = useRef<HTMLImageElement>(null);
const paths = [
"M145 82 C330 82 330 235 480 260",
"M145 260 C300 260 360 260 480 260",
"M145 438 C330 438 330 285 480 260",
"M480 260 C630 235 630 82 815 82",
"M480 260 C600 260 660 260 815 260",
"M480 260 C630 285 630 438 815 438",
];
return (
<div className={"bg-black/[.10] py-20"}>
<h2 className={"text-center lg:text-5xl text-3xl font-extrabold mb-24"}>
export const DataflowVisualizationComponent = () => (
<section className={styles.dataflowSection}>
<div className={styles.dataflowHeading}>
<h2>
No YAML configurations.
<br />
Easy and quick to manage integrations.
</h2>
<p>Homarr brings status, controls, and updates together in one dashboard.</p>
</div>
<div className="relative max-w-128 h-80 mx-auto animated-dataflow mx-5">
<img
ref={homarrRef}
className={"absolute left-1/2 top-1/2 -translate-y-1/2 -translate-x-1/2 aspect-square object-contain"}
src={"/img/logo.png"}
alt={"Homarr Logo"}
width={100}
height={50}
/>
<div
className={styles.dataflowDiagram}
role="img"
aria-label={`Information flowing both ways between Homarr and rotating examples from ${cyclingIntegrations.length} supported integrations`}
>
<svg className={styles.dataflowLines} viewBox="0 0 960 520" preserveAspectRatio="none" aria-hidden="true">
{paths.map((path) => (
<g key={path}>
<path className={styles.dataflowLine} d={path} pathLength="1" />
<path className={styles.dataflowSignal} d={path} pathLength="1" />
<path className={styles.dataflowSignalReverse} d={path} pathLength="1" />
</g>
))}
</svg>
<img
ref={radarrRef}
className={"absolute left-0 top-0"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/radarr.png?raw=true"}
alt={"Radarr"}
width={50}
height={50}
/>
<img
ref={sonarrRef}
className={"absolute left-0 top-1/2 -translate-y-1/2"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/sonarr.png?raw=true"}
alt={"Sonarr"}
width={50}
height={50}
/>
<img
ref={lidarrRef}
className={"absolute left-0 bottom-0"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/lidarr.png?raw=true"}
alt={"Lidarr"}
width={50}
height={50}
/>
{nodePositions.map((position, nodeIndex) => {
const cycleStepInSeconds = 1.2 + nodeIndex * 0.13;
const phaseOffsetInSeconds = nodeIndex * 2.7;
<img
ref={jellyfinRef}
className={"absolute right-0 top-1/2 -translate-y-1/2"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true"}
alt={"Jellyfin"}
width={50}
height={50}
/>
return (
<div className={`${styles.dataflowNode} ${position}`} key={position}>
<div className={styles.dataflowCyclingIcons} aria-hidden="true">
{cyclingIntegrations.map((_, integrationIndex) => {
const integration =
cyclingIntegrations[(integrationIndex + nodeIndex * 4) % cyclingIntegrations.length];
<img
ref={plexRef}
className={"absolute right-0 bottom-0"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/plex.png?raw=true"}
alt={"Plex"}
width={50}
height={50}
/>
return (
<img
src={integration.iconUrl}
alt=""
width={54}
height={54}
key={integration.name}
style={{
animationDelay: `${-(integrationIndex * cycleStepInSeconds + phaseOffsetInSeconds)}s`,
animationDuration: `${cyclingIntegrations.length * cycleStepInSeconds}s`,
}}
/>
);
})}
</div>
</div>
);
})}
<img
ref={sabnzbdRef}
className={"absolute right-0 top-0"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/sabnzbd.png?raw=true"}
alt={"Sabnzbd"}
width={50}
height={50}
/>
<LineTree start={sonarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
<LineTree start={radarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
<LineTree start={lidarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
<LineTree start={plexRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
<LineTree start={jellyfinRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
<LineTree start={sabnzbdRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
<div className={`${styles.dataflowNode} ${styles.dataflowHub}`}>
<img src="/img/logo.png" alt="" width={86} height={86} />
</div>
</div>
);
};
export const LineTree = ({
start,
end,
startSocket,
endSocket,
x = 0,
}: {
start: any;
end: any;
startSocket: SocketType;
endSocket: SocketType;
x?: number;
}) => {
const line = useRef<LeaderLine | null>(null);
let leaderLine: LeaderLine;
React.useEffect(() => {
const drawLine = () => {
leaderLine = new LeaderLine(start.current, LeaderLine.pointAnchor(end.current, { x: `${x}%` }), {
startSocket: startSocket,
endSocket: endSocket,
color: "var(--ifm-color-primary)",
size: 3,
dash: {
animation: true,
},
});
};
const timer = setInterval(() => {
if (start.current) {
clearInterval(timer);
drawLine();
}
}, 5);
return () => {
timer && clearInterval(timer);
leaderLine.remove();
};
}, []);
React.useEffect(() => {
// scroll and resize listeners could be assigned here
setTimeout(() => {
// skip current even loop and wait
// the end of parent's render call
if (line.current && end?.current) {
(line.current as any).position();
}
}, 0);
});
return null;
};
</section>
);
+200 -3
View File
@@ -230,6 +230,172 @@ section {
font-weight: 700;
}
.dataflowSection {
margin-bottom: 0;
padding: clamp(4rem, 8vw, 7rem) clamp(1rem, 4vw, 3rem);
overflow: hidden;
background: rgb(0 0 0 / 10%);
}
.dataflowHeading {
max-width: 58rem;
margin: 0 auto clamp(2rem, 5vw, 4rem);
text-align: center;
}
.dataflowHeading h2 {
margin-bottom: 1rem;
font-size: clamp(2rem, 5vw, 4rem);
line-height: 1.08;
letter-spacing: -0.03em;
text-wrap: balance;
}
.dataflowHeading p {
margin: 0 auto;
max-width: 42rem;
color: color-mix(in srgb, var(--ifm-font-color-base) 76%, transparent);
font-size: clamp(1rem, 2vw, 1.25rem);
text-wrap: pretty;
}
.dataflowDiagram {
position: relative;
width: min(100%, 70rem);
aspect-ratio: 960 / 520;
margin-inline: auto;
}
.dataflowLines {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.dataflowLine,
.dataflowSignal,
.dataflowSignalReverse {
fill: none;
stroke-linecap: round;
}
.dataflowLine {
stroke: color-mix(in srgb, var(--ifm-color-primary) 30%, transparent);
stroke-width: 3;
}
.dataflowSignal,
.dataflowSignalReverse {
stroke: var(--ifm-color-primary);
stroke-width: 7;
stroke-dasharray: 0.035 0.965;
animation: dataflow-signal 2.8s linear infinite;
}
.dataflowSignalReverse {
animation-direction: reverse;
animation-delay: -1.4s;
}
.dataflowNode {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
box-sizing: border-box;
width: clamp(4.5rem, 10vw, 7.5rem);
aspect-ratio: 1;
padding: clamp(0.6rem, 1.5vw, 1rem);
border-radius: 0.875rem;
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
box-shadow: 0 6px 12px rgb(31 41 55 / 14%);
transform: translate(-50%, -50%);
}
.dataflowNode img {
width: clamp(2rem, 4.5vw, 3.4rem);
height: clamp(2rem, 4.5vw, 3.4rem);
object-fit: contain;
}
.dataflowCyclingIcons {
position: relative;
width: clamp(2rem, 4.5vw, 3.4rem);
aspect-ratio: 1;
}
.dataflowCyclingIcons img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
animation: dataflow-icon-cycle linear infinite;
}
.dataflowNodeTopLeft,
.dataflowNodeMiddleLeft,
.dataflowNodeBottomLeft {
left: 15%;
}
.dataflowNodeTopRight,
.dataflowNodeMiddleRight,
.dataflowNodeBottomRight {
left: 85%;
}
.dataflowNodeTopLeft,
.dataflowNodeTopRight {
top: 16%;
}
.dataflowNodeMiddleLeft,
.dataflowNodeMiddleRight,
.dataflowHub {
top: 50%;
}
.dataflowNodeBottomLeft,
.dataflowNodeBottomRight {
top: 84%;
}
.dataflowHub {
left: 50%;
width: clamp(6rem, 13vw, 9rem);
}
.dataflowHub img {
width: clamp(3rem, 7vw, 5.4rem);
height: clamp(3rem, 7vw, 5.4rem);
}
@keyframes dataflow-signal {
to {
stroke-dashoffset: -1;
}
}
@keyframes dataflow-icon-cycle {
0%,
3.7% {
opacity: 1;
transform: scale(1);
}
4.2%,
99% {
opacity: 0;
transform: scale(0.82);
}
}
:global([data-theme="dark"]) .resourceMeterCritical .resourceLabel,
:global([data-theme="dark"]) .containerRowFailed {
color: #ffe3e3;
@@ -280,8 +446,39 @@ section {
}
}
@media (prefers-reduced-motion: reduce) {
.statusDotFailed {
animation: none;
@media screen and (max-width: 640px) {
.dataflowSection {
padding-block: 4rem;
}
.dataflowHeading {
margin-bottom: 2rem;
}
.dataflowDiagram {
aspect-ratio: 4 / 5;
}
.dataflowNode {
width: 4.5rem;
padding: 0.6rem;
}
}
@media (prefers-reduced-motion: reduce) {
.statusDotFailed,
.dataflowSignal,
.dataflowSignalReverse,
.dataflowCyclingIcons img {
animation: none;
}
.dataflowSignal,
.dataflowSignalReverse {
display: none;
}
.dataflowCyclingIcons img:first-child {
opacity: 1;
}
}
+2 -8
View File
@@ -5,9 +5,9 @@ import Layout from "@theme/Layout";
import HomepageFeatures from "../components/pages/home/features/features";
import HomepageUserReviews from "../components/pages/home/review-list/review-list";
import HomeHero from "../components/pages/home/hero/hero";
import { DataflowVisualizationComponent } from "../components/pages/home/dataflow/dataflow";
import styles from "./index.module.css";
import BrowserOnly from "@docusaurus/BrowserOnly";
import { SectionContainer } from "@site/src/components/pages/home/container/section-container";
import { DragAndDropShowcase } from "@site/src/components/pages/home/drag-and-drop/drag-and-drop-showcase";
import { AvailableIntegrations } from "@site/src/components/pages/home/integrations/available-integrations";
@@ -32,13 +32,7 @@ export default function Home() {
<SectionContainer>
<HomepageFeatures />
</SectionContainer>
<BrowserOnly fallback={<span>loading...</span>}>
{() => {
const DataflowVisualizationComponent =
require("../components/pages/home/dataflow/dataflow").DataflowVisualizationComponent;
return <DataflowVisualizationComponent />;
}}
</BrowserOnly>
<DataflowVisualizationComponent />
<DragAndDropShowcase />
<AvailableIntegrations />
<SectionContainer>
+1 -7
View File
@@ -1266,15 +1266,9 @@
"properties": {
"boardId": {
"type": "string"
},
"name": {
"type": "string"
},
"layoutId": {
"type": "string"
}
},
"required": ["boardId", "name", "layoutId"],
"required": ["boardId"],
"additionalProperties": false
}
}
-1
View File
@@ -46,7 +46,6 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
preloadEntriesOnStart: false,
turbopackFileSystemCacheForBuild: true,
useTypeScriptCli: true,
},
+2 -2
View File
@@ -48,7 +48,8 @@
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/onboarding": "workspace:^0.1.0",
"@homarr/old-import": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
@@ -68,7 +69,6 @@
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@rexa-developer/tiks": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-devtools": "catalog:",
@@ -4,12 +4,10 @@ import { Box, Paper, Stack, Text } from "@mantine/core";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client";
import { getRepresentativeLayoutWidth } from "../_layout-utils";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
@@ -66,8 +64,6 @@ export const ClientBoard = () => {
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
@@ -50,11 +50,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
const requestedBoardName =
typeof resolvedParams.name === "string" ? `/boards/${encodeURIComponent(resolvedParams.name)}` : null;
redirect(
requestedBoardName ? `/auth/login?callbackUrl=${encodeURIComponent(requestedBoardName)}` : "/auth/login",
);
redirect("/auth/login");
}
notFound();
@@ -1,15 +1,18 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -17,41 +20,60 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const { openModal } = useModalAction(UniversalCreateModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const t = useI18n();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const handledInitialAdd = useRef(false);
useEffect(() => {
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
handledInitialAdd.current = true;
addWidget();
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("add");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [addWidget, board.id, pathname, router, searchParams]);
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({});
};
return (
<HeaderButton
aria-label={t("board.action.addContent")}
onClick={() =>
openModal({
boardActions: {
widget: addWidget,
app: addApp,
integration: canConnectService ? connectService : undefined,
container: addContainer,
},
})
}
>
<IconPlus stroke={1.5} />
</HeaderButton>
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{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={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -5,7 +5,6 @@ import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconDeviceFloppy,
IconLayoutBoard,
@@ -99,9 +98,6 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onMutate(boardToSave) {
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
},
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
@@ -21,7 +21,6 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import { generateColors } from "../../(content)/_theme";
@@ -45,11 +44,21 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
<ColorInput
label={t("board.field.primaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("primaryColor")}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
<ColorInput
label={t("board.field.secondaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("secondaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
@@ -4,21 +4,21 @@ import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/cor
import { IconLayoutGrid } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { Board } from "../../_types";
import type { BoardLayout } from "../../_layout-utils";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "../../_layout-utils";
import classes from "./_layout-preview.module.css";
interface Props {
board: Board;
layout: BoardPreviewLayout;
layouts: BoardPreviewLayout[];
sourceLayout: BoardPreviewLayout;
layout: BoardLayout;
layouts: BoardLayout[];
sourceLayout: BoardLayout;
apps: RouterOutputs["app"]["byIds"];
}
@@ -222,17 +222,15 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
/>
)}
</Grid.Col>
{layout.role !== "mobile" && (
<Grid.Col span={{ base: 12, lg: 4 }}>
<GutterSettings
left={layout.leftGutterColumnCount}
right={layout.rightGutterColumnCount}
columnCount={layout.columnCount}
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
/>
</Grid.Col>
)}
<Grid.Col span={{ base: 12, lg: 4 }}>
<GutterSettings
left={layout.leftGutterColumnCount}
right={layout.rightGutterColumnCount}
columnCount={layout.columnCount}
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
/>
</Grid.Col>
</Grid>
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
@@ -36,11 +36,6 @@ const boardSettingsFormSchema = boardSavePartialSettingsSchema
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
const normalizeMobileLayoutGutters = (layouts: readonly FormValues["layouts"][number][]) =>
layouts.map((layout) =>
layout.role === "mobile" ? { ...layout, leftGutterColumnCount: 0, rightGutterColumnCount: 0 } : layout,
);
const PARTIAL_FORM_KEYS = [
"pageTitle",
"metaTitle",
@@ -75,7 +70,7 @@ const buildInitialValues = (board: Board): FormValues => ({
itemRadius: board.itemRadius,
customCss: board.customCss ?? "",
disableStatus: board.disableStatus,
layouts: normalizeMobileLayoutGutters(board.layouts),
layouts: board.layouts,
});
interface BoardSettingsFormProps {
@@ -132,8 +127,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
const { layouts: submittedLayouts, ...partialSettings } = values;
const layouts = normalizeMobileLayoutGutters(submittedLayouts);
const { layouts, ...partialSettings } = values;
const partialSettingsChanged = changed(...PARTIAL_FORM_KEYS);
const layoutsChanged = changed("layouts");
if (!partialSettingsChanged && !layoutsChanged) return values;
@@ -0,0 +1,111 @@
import { describe, expect, test } from "vitest";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils";
const mobile = {
id: "mobile",
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile" as const,
};
const custom = {
id: "tablet",
name: "Tablet",
columnCount: 6,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 480,
role: "custom" as const,
};
const base = {
id: "base",
name: "Base",
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 768,
role: "base" as const,
};
describe("layout representative widths", () => {
test("uses a phone width, activation midpoint, and desktop minimum", () => {
const layouts = [mobile, custom, base];
expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390);
expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624);
expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280);
});
});
describe("projectBoardLayout", () => {
test("returns exact saved positions without recursively re-projecting the active layout", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const container = new ContainerSectionMockBuilder({ id: "container" })
.addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 })
.build();
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build();
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }),
]);
});
test("uses production ordering and sizing for items and containers", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const container = new ContainerSectionMockBuilder({ id: "container" })
.addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 })
.build();
const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" })
.addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 })
.build();
const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" })
.addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
.build();
const board = new BoardMockBuilder()
.addLayout(mobile)
.addSections([rootSection, container])
.addItems([rootItem, nestedItem])
.build();
const projected = projectBoardLayout(board, base, mobile);
expect(projected.find((element) => element.id === rootItem.id)).toMatchObject({
type: "item",
width: 3,
height: 2,
xOffset: 0,
yOffset: 0,
sectionId: rootSection.id,
});
expect(projected.find((element) => element.id === container.id)).toMatchObject({
type: "section",
width: 3,
xOffset: 0,
yOffset: 2,
sectionId: rootSection.id,
});
expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({
type: "item",
width: 3,
sectionId: container.id,
});
});
test("ignores missing saved positions and supports empty boards", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const itemWithoutBasePosition = new ItemMockBuilder({ id: "missing", kind: "clock" }).build();
const board = new BoardMockBuilder()
.addLayout(mobile)
.addSection(rootSection)
.addItem(itemWithoutBasePosition)
.build();
expect(projectBoardLayout(board, base, mobile)).toEqual([]);
});
});
@@ -1,66 +1,14 @@
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
import type { BoardLane, LayoutRole, WidgetKind } from "@homarr/definitions";
import type { BoardLane } from "@homarr/definitions";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
export interface BoardPreviewLayout {
id: string;
columnCount: number;
leftGutterColumnCount: number;
rightGutterColumnCount: number;
breakpoint: number;
role: LayoutRole;
}
import type { Board } from "./_types";
interface BoardPreviewItemLayout {
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
width: number;
height: number;
}
export type BoardLayout = Board["layouts"][number];
type LayoutGeometry = Pick<BoardLayout, "id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount">;
interface BoardPreviewSectionLayout {
layoutId: string;
parentSectionId: string | null;
xOffset: number;
yOffset: number;
width: number;
height: number;
}
export interface BoardPreviewData {
layouts: readonly BoardPreviewLayout[];
sections: readonly BoardPreviewSection[];
items: readonly {
id: string;
kind?: WidgetKind;
iconUrl?: string | null;
layouts: readonly BoardPreviewItemLayout[];
}[];
}
type BoardPreviewSection =
| {
id: string;
kind: "empty";
xOffset: number | null;
layouts?: readonly BoardPreviewSectionLayout[];
}
| {
id: string;
kind: "container";
xOffset?: number | null;
layouts: readonly BoardPreviewSectionLayout[];
};
type LayoutGeometry = Pick<
BoardPreviewLayout,
"id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount"
>;
export const getRepresentativeLayoutWidth = (layout: BoardPreviewLayout, allLayouts: readonly BoardPreviewLayout[]) => {
export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => {
if (layout.role === "mobile") return 390;
if (layout.role === "base") return Math.max(1280, layout.breakpoint);
@@ -72,11 +20,7 @@ export const getRepresentativeLayoutWidth = (layout: BoardPreviewLayout, allLayo
return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint);
};
export const projectBoardLayout = (
board: BoardPreviewData,
sourceLayout: LayoutGeometry,
targetLayout: LayoutGeometry,
) => {
export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => {
const elements = getElementsForLayout(board, sourceLayout.id);
if (
@@ -90,7 +34,7 @@ export const projectBoardLayout = (
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
.toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id));
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
for (const root of emptyRoots) {
const lane = getRootSectionLane(root.xOffset);
@@ -141,7 +85,7 @@ export const projectBoardLayout = (
});
};
const getElementsForLayout = (board: BoardPreviewData, layoutId: string): GridAlgorithmItem[] => [
const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [
...board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout
@@ -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";
@@ -0,0 +1,23 @@
"use client";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client";
export const BackToStart = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => {
await mutateAsync();
await revalidatePathActionAsync("/init");
};
return (
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
{t("init.backToStart")}
</Button>
);
};
@@ -0,0 +1,75 @@
import type { MantineColor } from "@mantine/core";
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 { 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 tFinish = await getScopedI18n("init.step.finish");
return (
<InitStepCard>
<Stack>
<Text>{tFinish("description")}</Text>
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
{tFinish("action.goToBoard", { name: "dashboard" })}
</InternalLinkButton>
{isProviderEnabled("credentials") && (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/users/invites"
iconProps={{ icon: IconMailForward, color: "pink" }}
>
{tFinish("action.inviteUser")}
</InternalLinkButton>
)}
<ExternalLinkButton
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
iconProps={{ icon: IconBook2, color: "yellow" }}
>
{tFinish("action.docs")}
</ExternalLinkButton>
</Stack>
</InitStepCard>
);
};
interface LinkButtonProps {
href: string;
children: string;
iconProps: IconProps;
}
interface IconProps {
icon: TablerIcon;
color: MantineColor;
}
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
};
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
@@ -0,0 +1,55 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
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({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
},
});
const handleSubmitAsync = async (values: z.infer<typeof groupCreateSchema>) => {
await mutateAsync(values, {
onError(error) {
if (error.data?.code === "CONFLICT") {
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
}
},
});
};
return (
<InitStepCard>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<TextInput
label={t("init.step.group.form.name.label")}
description={t("init.step.group.form.name.description")}
withAsterisk
{...form.getInputProps("name")}
/>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
</InitStepCard>
);
};
@@ -0,0 +1,43 @@
import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { formatBytes } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
interface FileInfoCardProps {
file: FileWithPath;
onRemove: () => void;
}
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return (
<InitStepCard withBorder={false}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{formatBytes(file.size)}
</Text>
</Group>
<Button
variant="subtle"
color="gray"
rightSection={<IconPencil size={16} stroke={1.5} />}
onClick={onRemove}
visibleFrom="md"
>
{tFileInfo("action.change")}
</Button>
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</Group>
</InitStepCard>
);
};
@@ -0,0 +1,63 @@
import { Group, rem, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportDropZoneProps {
loading: boolean;
updateFile: (file: FileWithPath) => void;
}
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
const tDropzone = useScopedI18n("init.step.import.dropzone");
return (
<Dropzone
onDrop={(files) => {
const firstFile = files[0];
if (!firstFile) return;
updateFile(firstFile);
}}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
loading={loading}
multiple={false}
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
onReject={(rejections) => {
console.error(
"Rejected files",
rejections.map(
(rejection) =>
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
),
);
}}
>
<Group justify="center" gap="xl" mih={220} 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>
{tDropzone("title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{tDropzone("description")}
</Text>
</div>
</Group>
</Dropzone>
);
};
@@ -0,0 +1,54 @@
"use client";
import { startTransition, useState } from "react";
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";
export const InitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
null,
);
if (!file) {
return (
<InitStepCard>
<ImportDropZone
loading={isPending}
updateFile={(file) => {
const formData = new FormData();
formData.append("file", file);
mutate(formData, {
onSuccess: (result) => {
startTransition(() => {
setAnalyseResult(result);
setFile(file);
});
},
onError: (error) => {
console.error(error);
},
});
}}
/>
</InitStepCard>
);
}
return (
<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>
);
};
@@ -0,0 +1,129 @@
"use client";
import { Button, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import type { CheckboxProps } from "@homarr/form/types";
import { defaultServerSettings } from "@homarr/server-settings";
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();
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const form = useZodForm(settingsInitSchema, { initialValues: defaultServerSettings });
const handleSubmitAsync = async (values: z.infer<typeof settingsInitSchema>) => {
await mutateAsync(values);
};
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack w="100%">
<InitStepCard>
<Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text>
<Stack gap="xs">
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
</Stack>
</Stack>
</InitStepCard>
<InitStepCard>
<Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
<Stack gap="xs">
<CrawlingRow
kind="noIndex"
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
/>
<CrawlingRow
kind="noFollow"
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
/>
<CrawlingRow
kind="noTranslate"
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
/>
<CrawlingRow
kind="noSiteLinksSearchBox"
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
/>
</Stack>
</Stack>
</InitStepCard>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
);
};
interface AnalyticsRowProps {
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
disabled?: boolean;
}
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
);
};
interface CrawlingRowProps {
kind: Exclude<
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
"title" | "warning"
>;
}
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow
title={tSection(`crawlingAndIndexing.${kind}.title`)}
text={tSection(`crawlingAndIndexing.${kind}.text`)}
{...inputProps}
/>
);
};
const SettingRow = ({
title,
text,
disabled,
...inputProps
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
return (
<Group wrap="nowrap" align="center">
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{title}
</Text>
<Text size="xs" c="gray.5">
{text}
</Text>
</Stack>
<Switch disabled={disabled} {...inputProps} />
</Group>
);
};
@@ -0,0 +1,37 @@
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 (
<InitStepCard>
<Stack>
<Text>{tStart("description")}</Text>
<InitStartButton
preferredStep={undefined}
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
>
{tStart("action.scratch")}
</InitStartButton>
<InitStartButton
preferredStep="import"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importOldmarr")}
</InitStartButton>
{isSqlite && <InitSqliteRestore />}
</Stack>
</InitStepCard>
);
};
@@ -0,0 +1,28 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions";
interface InitStartButtonProps {
icon: ReactNode;
preferredStep: OnboardingStep | undefined;
}
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
return (
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
{children}
</Button>
);
};
@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import { Button, 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>
);
};
@@ -0,0 +1,75 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { userInitSchema } from "@homarr/validation/user";
export const InitUserForm = () => {
const t = useScopedI18n("user");
const tUser = useScopedI18n("init.step.user");
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
const form = useZodForm(userInitSchema, {
initialValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, {
onSuccess() {
showSuccessNotification({
title: tUser("notification.success.title"),
message: tUser("notification.success.message"),
});
void signIn("credentials", {
name: values.username,
password: values.password,
redirect: false,
}).then(async () => {
await revalidatePathActionAsync("/init");
});
},
onError: (error) => {
showErrorNotification({
title: tUser("notification.error.title"),
message: error.message,
});
},
});
};
return (
<Stack gap="xl">
<form
onSubmit={form.onSubmit(
(values) => void handleSubmitAsync(values),
(err) => console.log(err),
)}
>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<UserCreatePasswordFields
passwordInputProps={form.getInputProps("password")}
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
/>
<Button type="submit" fullWidth loading={isPending}>
{t("action.create")}
</Button>
</Stack>
</form>
</Stack>
);
};
type FormType = z.infer<typeof userInitSchema>;
@@ -0,0 +1,10 @@
import { InitStepCard } from "../../_components/init-step-card";
import { InitUserForm } from "./init-user-form";
export const InitUser = () => {
return (
<InitStepCard>
<InitUserForm />
</InitStepCard>
);
};
+58 -70
View File
@@ -1,78 +1,66 @@
import { headers } from "next/headers";
import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { getOnboardingClaimTokenFromCookieHeader, isOnboardingClaimValidAsync } from "@homarr/api/onboarding-claim";
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { db, eq } from "@homarr/db";
import { groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
import { env as dockerEnv } from "@homarr/docker/env";
import { OnboardingStudio } from "@homarr/onboarding";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import type { MaybePromise } from "@homarr/common/types";
import { db } from "@homarr/db";
import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { DatabaseRestoreFlow } from "~/components/backup";
import { env } from "~/env";
import { getPackageVersion } from "~/versions/package-reader";
import { AssistantConfiguration } from "../manage/assistant/_components/assistant-configuration";
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";
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
start: InitStart,
import: InitImport,
user: InitUser,
group: InitGroup,
settings: InitSettings,
integrations: InitIntegrations,
finish: InitFinish,
};
const getCurrentOnboardingStepAsync = async () => {
const value = await db.query.onboarding.findFirst();
if (!value) return { current: "start" as const, previous: null };
return { current: value.step, previous: value.previousStep };
};
export default async function InitPage() {
const [state, session, requestHeaders, firstUser] = await Promise.all([
db.query.onboarding.findFirst(),
auth(),
headers(),
db.query.users.findFirst({ columns: { id: true } }),
]);
const canConfigurePrivileged = session?.user.permissions.includes("admin") ?? false;
const onboardingClaim = getOnboardingClaimTokenFromCookieHeader(requestHeaders.get("cookie"));
const canReadSetupContext =
canConfigurePrivileged || (!firstUser && (await isOnboardingClaimValidAsync(db, onboardingClaim)));
const [defaultGroup, availableBoards] = canReadSetupContext
? await Promise.all([
db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
columns: { homeBoardId: true },
}),
db.query.boards.findMany({ columns: { id: true, name: true, primaryColor: true, secondaryColor: true } }),
])
: [undefined, []];
const homeBoardId = defaultGroup?.homeBoardId;
const homeBoard = availableBoards.find((board) => board.id === homeBoardId);
const initialBoard = homeBoard ?? (availableBoards.length === 1 ? availableBoards[0] : null);
const currentStep = normalizeOnboardingStep(state?.step);
const databaseDriver =
dbEnv.DRIVER === "better-sqlite3" ? "sqlite" : dbEnv.DRIVER === "mysql2" ? "mysql" : "postgresql";
const { workshopApiUrl, workshopWebUrl } = canReadSetupContext
? resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
})
: { workshopApiUrl: "", workshopWebUrl: "" };
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
const t = await getScopedI18n("init.step");
const currentStep = await getCurrentOnboardingStepAsync();
const CurrentComponent = stepComponents[currentStep.current];
return (
<OnboardingStudio
environment={{
version: getPackageVersion(),
currentStep,
databaseDriver,
externalAuthEnabled: isProviderEnabled("ldap") || isProviderEnabled("oidc"),
dockerConfigured: canReadSetupContext && dockerEnv.ENABLE_DOCKER,
kubernetesConfigured: canReadSetupContext && dockerEnv.ENABLE_KUBERNETES,
workshopApiUrl,
workshopUrl: workshopWebUrl,
serverOrigin: baseUrl,
mcpEndpoint: `${baseUrl}/api/mcp`,
canConfigurePrivileged,
hasUsers: Boolean(firstUser),
initialBoard: initialBoard ?? null,
availableBoards: availableBoards.map(({ id, name }) => ({ id, name })),
}}
sqliteRestore={databaseDriver === "sqlite" ? <DatabaseRestoreFlow variant="standalone" /> : undefined}
assistantConfiguration={canConfigurePrivileged ? <AssistantConfiguration /> : undefined}
/>
<Box mih="100dvh">
<Center>
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t(`${currentStep.current}.title`)}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t(`${currentStep.current}.subtitle`)}
</Text>
</Stack>
<CurrentLanguageCombobox width="100%" />
<CurrentColorSchemeCombobox w="100%" />
{CurrentComponent && <CurrentComponent />}
{currentStep.previous === "start" && <BackToStart />}
</Stack>
</Center>
</Box>
);
}
+2 -7
View File
@@ -5,7 +5,6 @@ import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "flag-icons/css/flag-icons.min.css";
import "mantine-datatable/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
@@ -174,12 +173,8 @@ export default async function Layout(props: {
>
<head>
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
{session ? (
<>
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
</>
) : null}
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
@@ -21,4 +21,26 @@
.scrollContainer {
height: 100%;
transform: rotateZ(10deg);
}
@keyframes scrolling {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50.8%);
}
}
.scrollAnimationContainer {
animation: scrolling;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.scrollAnimationContainer {
animation: none !important;
}
}
@@ -1,10 +1,20 @@
import { Box, Group, Image, Stack, Title } from "@mantine/core";
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationMarquee } from "@homarr/ui";
import classes from "./hero-banner.module.css";
const icons = Object.values(integrationDefs)
.filter((int) => int.name !== "Mock")
.map((int) => int.iconUrl);
const countIconGroups = 3;
const animationDurationInSeconds = icons.length;
const arrayInChunks = splitToNChunks(icons, countIconGroups);
const gridSpan = 12 / countIconGroups;
export const HeroBanner = async () => {
const t = await getScopedI18n("management.page.home");
@@ -20,7 +30,41 @@ export const HeroBanner = async () => {
</Group>
</Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
<IntegrationMarquee />
<Grid>
{Array(countIconGroups)
.fill(0)
.map((_, columnIndex) => (
<GridCol key={`grid-column-${columnIndex}`} span={gridSpan}>
<Stack
className={classes.scrollAnimationContainer}
style={{
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
}}
>
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon}
fit={"contain"}
w={50}
h={50}
/>
))}
{/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon}
fit={"contain"}
w={50}
h={50}
/>
))}
</Stack>
</GridCol>
))}
</Grid>
</Box>
</Box>
);
@@ -17,7 +17,6 @@ interface AppDeleteButtonProps {
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useScopedI18n("app.page.delete");
const tList = useScopedI18n("app.page.list.action");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
@@ -51,14 +50,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
}, [app, mutate, t, openConfirmModal]);
return (
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={tList("delete", { name: app.name })}
>
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
@@ -16,7 +16,10 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) {
if (
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
notFound();
}
const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("app-create")) {
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
notFound();
}
+92 -116
View File
@@ -1,37 +1,26 @@
import { Fragment } from "react";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getSafeAppHref } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button";
const searchParamsSchema = z.object({
search: z.string().optional(),
pageSize: z
.string()
.regex(/^[1-9]\d*$/u)
.transform(Number)
.pipe(z.number().int().positive())
.catch(10),
page: z
.string()
.regex(/^[1-9]\d*$/u)
.transform(Number)
.pipe(z.number().int().positive())
.catch(1),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
interface AppsPageProps {
@@ -40,38 +29,24 @@ interface AppsPageProps {
export default async function AppsPage(props: AppsPageProps) {
const session = await auth();
if (!session) redirect("/auth/login");
if (!session) {
redirect("/auth/login");
}
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app");
const canCreate = session.user.permissions.includes("app-create");
const canModify = session.user.permissions.includes("app-modify-all");
const canDelete = session.user.permissions.includes("app-full-all");
const hasSearch = Boolean(searchParams.search?.trim());
const emptyState = hasSearch ? (
<NoResults
icon={IconBox}
title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
/>
) : (
<NoResults
icon={IconBox}
title={t("page.list.noResults.title")}
description={t("page.list.noResults.description")}
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new", hidden: !canCreate }}
/>
);
const page = (
<ManageCollectionPage
return (
<ManagePageLayout
title={t("page.list.title")}
ariaLabel={t("page.list.ariaLabel")}
itemCount={apps.length}
emptyState={emptyState}
primaryAction={
canCreate ? (
<TourTarget id="manage-apps-create">
@@ -81,100 +56,101 @@ export default async function AppsPage(props: AppsPageProps) {
</TourTarget>
) : undefined
}
toolbar={
<SearchInput
placeholder={`${t("search")}...`}
ariaLabel={t("search")}
defaultValue={searchParams.search}
flexExpand
/>
}
footer={
totalCount > searchParams.pageSize ? (
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
) : undefined
}
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
floatingPrimaryAction={canCreate}
>
{apps.map((app) => (
<AppItem
key={app.id}
app={app}
canModify={canModify}
canDelete={canDelete}
editLabel={t("page.list.action.edit", { name: app.name })}
/>
))}
</ManageCollectionPage>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<TourTarget id="manage-apps-list">
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
</TourTarget>
)}
</ManagePageLayout>
);
return apps.length > 0 ? <TourTarget id="manage-apps-list">{page}</TourTarget> : page;
}
interface AppItemProps {
app: RouterOutputs["app"]["getPaginated"]["items"][number];
canModify: boolean;
canDelete: boolean;
editLabel: string;
interface AppCardProps {
app: RouterOutputs["app"]["all"][number];
}
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
const descriptionLines = app.description?.split("\n");
const safeHref = getSafeAppHref(app.href);
const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app");
const session = await auth();
return (
<ManageCollectionItem
leading={<Avatar size={36} src={app.iconUrl} radius="sm" styles={{ image: { objectFit: "contain" } }} alt="" />}
title={
<Text component="span" fw={600} lineClamp={1}>
{app.name}
</Text>
}
description={
descriptionLines ? (
<Text size="sm" c="dimmed" lineClamp={2}>
{descriptionLines.map((line, index) => (
<Fragment key={index}>
{line}
{index < descriptionLines.length - 1 && <br />}
</Fragment>
))}
</Text>
) : undefined
}
metadata={
safeHref ? (
<Anchor
href={safeHref}
target="_blank"
rel="noreferrer"
lineClamp={1}
<Card>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
<Avatar
size="sm"
style={{ wordBreak: "break-all" }}
>
{app.href}
</Anchor>
) : undefined
}
actions={
canModify || canDelete ? (
src={app.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Stack gap={0} style={{ flex: "1" }}>
<Text fw={500} lineClamp={1}>
{app.name}
</Text>
{app.description && (
<Text size="sm" c="gray.6" lineClamp={4}>
{app.description.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
{app.href && (
<Anchor href={app.href} lineClamp={1} size="sm" style={{ wordBreak: "break-all" }}>
{app.href}
</Anchor>
)}
</Stack>
</Group>
<Group>
<ActionIconGroup>
{canModify && (
{session?.user.permissions.includes("app-modify-all") && (
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={editLabel}
aria-label={t("page.edit.title")}
>
<IconPencil size={18} stroke={1.5} />
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
)}
{canDelete && <AppDeleteButton app={app} />}
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
</ActionIconGroup>
) : undefined
}
</Group>
</Group>
</Card>
);
};
const AppNoResults = async () => {
const t = await getI18n();
const session = await auth();
return (
<NoResults
icon={IconBox}
title={t("app.page.list.noResults.title")}
action={{
label: t("app.page.list.noResults.action"),
href: "/manage/apps/new",
hidden: !session?.user.permissions.includes("app-create"),
}}
/>
);
};
@@ -142,8 +142,8 @@ export const AssistantConfiguration = () => {
const t = useScopedI18n("management.page.settings.section.assistant");
const utils = clientApi.useUtils();
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
const [provider, setProvider] = useState<AssistantProvider>("homarr");
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.homarr.baseUrl);
const [provider, setProvider] = useState<AssistantProvider>("openrouter");
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.openrouter.baseUrl);
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
const [apiKey, setApiKey] = useState("");
const [credentialFlow, setCredentialFlow] = useState<CredentialFlow>("idle");
@@ -155,7 +155,7 @@ export const AssistantConfiguration = () => {
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
useEffect(() => {
const configuredProvider = configuration?.provider ?? "homarr";
const configuredProvider = configuration?.provider ?? "openrouter";
setProvider(configuredProvider);
setBaseUrl(
configuredProvider === "homarr"
@@ -201,7 +201,7 @@ export const AssistantConfiguration = () => {
);
const providerOptions = useMemo(
() =>
(["free", "hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
const items = assistantProviderIds
.filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({
@@ -438,11 +438,6 @@ export const AssistantConfiguration = () => {
{option.label}
</Text>
</Group>
{option.value === "homarr" && (
<Text size="xs" c="dimmed" lineClamp={2}>
{t("provider.options.homarr.description")}
</Text>
)}
</Box>
</Group>
)}
@@ -22,7 +22,7 @@ const iconProps = {
interface BoardCardMenuDropdownProps {
board: Pick<
RouterOutputs["board"]["getManageOverview"][number],
RouterOutputs["board"]["getAllBoards"][number],
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
>;
}
@@ -1,23 +1,38 @@
"use client";
import { Button } from "@mantine/core";
import { IconCategoryPlus } from "@tabler/icons-react";
import { Button, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal } from "@homarr/modals-collection";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n();
const { openModal } = useModalAction(AddBoardModal);
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
return (
<ManageMobilePrimaryAction>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("management.page.board.action.new.label")}
</Button>
<Button.Group>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
{t("management.page.board.action.new.label")}
</Button>
<Menu position="bottom-end">
<Menu.Target>
<Button px="xs" ms={1}>
<IconChevronDown size="1rem" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
{t("board.action.oldImport.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Button.Group>
</ManageMobilePrimaryAction>
);
};
Loaded 100 of 653 files, more files were not shown because too many files have changed in this diff. Show more