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
512 changed files with 43312 additions and 7184 deletions

No files matched your search

+9 -1
View File
@@ -7,11 +7,15 @@ description: Author safe Homarr Custom JSX v2 widgets from API documentation.
Author one widget at a time. When connected to Homarr, retrieve `homarr://custom-widgets/schema` and only the component resources needed for the design. The live resources are authoritative for the installed release.
Keep documentation retrieval targeted: fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another component only when a validation issue or a concrete missing interaction requires it; never load the catalog component-by-component.
1. Read the API documentation.
2. Create one credential-free widget with keyed `sources`, keyed `requests`, optional keyed `options`, and safe JSX `template`.
3. Validate, preview, test queries and simulated actions, visually inspect, and iterate.
4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext.
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work.
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. When connected through MCP, persist the exact final tested preview with `customWidget_createFromPreview`; do not resend a large template through `customWidget_create` unless no preview session is available.
Treat a supplied sample or successful preview response as the binding contract. Render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show concise freshness context when the API supplies a timestamp. Give recoverable load errors and empty states a clear refresh or retry path.
Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$param` for values supplied by `SubFetch`, `ActionButton`, or `ToggleSwitch`. Load queries cannot use invocation parameters. Templates read `data`, `status`, `options`, and temporary `inputs`.
@@ -19,6 +23,10 @@ Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$para
Use standard Mantine compound names and deliberate visual hierarchy, spacing, restrained semantic color, and responsive layouts. Avoid excessive nested cards.
Aim for distinctive polish without decorative gradients or fragile custom styling: use one semantic accent, clear surface contrast, purposeful typography, and compact rows that remain scannable in narrow tiles. Give standalone icons an accessible label or pair them with visible text.
Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
Do not use imports, hooks, refs, raw HTML, raw event callbacks, browser requests, arbitrary functions, eval, bigint, npm packages, authored statement blocks, IIFEs, or recursion. Do not pretend MCP tools exist in an offline chat session.
To install an existing widget, use `customWidget_workshopSearch`, inspect it with `customWidget_workshopGet`, then use `customWidget_workshopInstall`. Configure each self-hosted source afterward with `customWidget_sourceConfigure`, or call `customWidget_configurationRequestUser` so the user can enter the server URL and credentials directly in Homarr.
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"label": "Advanced",
"collapsed": true,
"position": 4,
"position": 5,
"link": {
"type": "generated-index",
"title": "Advanced Concepts or features"
@@ -57,50 +57,8 @@ Homarr becomes the instance administrator.
Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
default data; use `pnpm db:seed` to seed it explicitly later.
### Run PocketBase and documentation
Start PocketBase in another terminal:
```sh
cp apps/workshop/.env.example apps/workshop/.env
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
```
Then start Docusaurus in a third terminal:
```sh
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
```
PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume.
Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at
`http://127.0.0.1:3003/workshop`.
Point Homarr at the local services with:
```dotenv
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
WORKSHOP_API_URL=http://127.0.0.1:8090
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
```
Restart `pnpm dev` after changing `.env`. GitHub OAuth is optional locally. See
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
### URL testing
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr also reads
`WORKSHOP_WEB_URL` when its server starts, so these values do not rely on `NEXT_PUBLIC_*` build variables. Test a
separate API origin before release:
```sh
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
WORKSHOP_API_URL=http://127.0.0.1:8090 \
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
pnpm dev:docs
```
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
[Workshop development guide](../../workshop/development).
### Useful commands
@@ -112,7 +70,6 @@ pnpm dev:docs
- `pnpm dev:cli -- rebuild <name>` — rebuild an image from its recorded checkout or pull request.
- `pnpm dev:cli:install` — optionally install the developer CLI as a `homarr` binary.
- `pnpm dev:docs` — Docusaurus only on port 3003.
- `pnpm docker:workshop` — local PocketBase through Docker Compose.
- `pnpm docker:dev:up` — Redis development service in the background.
- `pnpm cli` — Homarr operations CLI.
- `pnpm db:migration:sqlite:run` — create or update a SQLite database.
@@ -122,7 +79,6 @@ pnpm dev:docs
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
- `pnpm typecheck` — typecheck all workspaces.
- `pnpm test` — unit and contract suites.
- `pnpm test:workshop` — disposable Workshop integration suite.
- `pnpm build` — production monorepo build.
- `docker build -t homarr .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
@@ -37,6 +37,9 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_WEB_URL` | Public Workshop page opened from Homarr. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` |
Workshop service operators can find its server-only configuration in the
[Workshop operator guide](../../workshop/operator#environment-variables).
## Authentication
See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
@@ -109,12 +112,9 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing.
:::
| Environment Variable | Description |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. |
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. |
| Environment Variable | Description |
| ------------------------ | --------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for
`WORKSHOP_API_URL` for one release. `WORKSHOP_API_URL` takes precedence.
trailing slashes when Homarr or Docusaurus starts.
@@ -15,20 +15,6 @@ Homarr has the following keyboard shortcuts:
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
| `Esc` | Closes an open advanced view or board menu |
## Board editing
When a tile is focused in edit mode:
| Shortcut | Description |
| -------------------- | --------------------------------------------------------------------------- |
| `Enter` or `Space` | Starts or stops keyboard editing |
| Arrow keys | Moves the focused tile within its current canvas, rail, or Container |
| `Shift` + Arrow keys | Resizes from the tile's lower-right corner: Left/Up shrink; Right/Down grow |
| `Esc` | Stops keyboard editing without undoing its changes |
Keyboard moves and resizes apply immediately. To move an item between a canvas, rail, or Container, drag it there or use **Move / resize item** for precise numeric placement.
Commands at a grid boundary or at the tile's minimum size are ignored and announced instead of moving or shrinking the tile.
:::tip
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"label": "Community",
"position": 5,
"position": 6,
"collapsed": true,
"link": {
"type": "generated-index"
@@ -233,8 +233,7 @@ The tile snaps to the grid. Equally-sized tiles swap when one is dropped into th
Every `1 × 1` tile has a logical content size of `200 × 200` pixels.
Larger tiles use multiples of that fixed unit, with a consistent gap between cells.
Homarr scales the complete board uniformly, like zooming a page.
The complete canvas always fits the available width without horizontal board scrolling.
Below 100%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget independently.
It stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
:::note
+85
View File
@@ -0,0 +1,85 @@
---
description: Configure and use Homarr Assistant.
tags:
- Assistant
- AI
- Administration
---
# Assistant
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)
## 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.
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**.
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 instead.
:::
## Approvals and data
Read-only tools can run automatically. Changes show an approval card with the exact action before Homarr runs it.
Normal Homarr permissions still apply.
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.
Conversations are stored per user. Users can edit or retry messages, copy responses, export a conversation as Markdown,
and delete conversations.
## 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.
- **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.
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).
+4
View File
@@ -22,6 +22,8 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
## Exporting a Backup
![Export database card with the Download Backup action](/img/backup/export-database.png)
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
| File | Description |
@@ -33,6 +35,8 @@ The backup contains the complete database. It includes Custom Widgets and all ot
## Restoring a Backup
![Restore database card with the backup ZIP drop zone](/img/backup/restore-database.png)
### 1. Upload
Drag and drop or select a `.zip` backup file. The import card validates that the file contains a valid `db.sqlite` and metadata.
+6 -2
View File
@@ -107,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.
@@ -128,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.
@@ -247,6 +247,10 @@ Change the radius of your items and Containers.
This is the field where you can enter custom CSS.
The styling will only be applied on the respective board page.
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's Custom
CSS and other visual or behavior settings. The assistant reads the current stylesheet first and presents an
editable review form before requesting approval to save the change.
[See this guide on how to work with custom CSS](/docs/advanced/styling/).
### Behaviour
@@ -17,7 +17,9 @@ Install the Agent Skill from the verified Homarr repository:
npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget
```
The portable skill gives concise authoring guidance. The Homarr MCP prompt gives connected agents a retrieval workflow: read the live schema, then fetch only the component documentation needed for the widget.
The portable skill gives concise authoring guidance. For connected agents, Homarr returns the complete `SKILL.md` and
all bundled skill references once in one lazy tool response. The Homarr MCP prompt then gives the agent a
retrieval workflow: read the live schema and fetch only the component documentation needed for the widget.
The workbench's **Copy AI prompt** action is separate. It creates a self-contained prompt below 12,000 characters for an ordinary ChatGPT or Claude conversation. It includes the lean contract, bindings, recommended components, and two polished examples. It never tells an offline model to call MCP tools.
@@ -37,38 +39,55 @@ Repository code cannot give this attribution.
The authoring-prompt tool description contains one sentence. The prompt contains the workflow instructions and resource links.
`GET /api/custom-widgets/skill` and the MCP tool return the concise skill discovery payload:
`GET /api/custom-widgets/skill` and `customWidget_getSkill` return a self-contained structured skill payload:
```ts
{
name: "homarr-custom-widget";
version: string;
skillMd: string;
skillMd: string; // Complete SKILL.md
references: Record<string, string>;
skillsShUrl: string;
sourceUrl: string;
installCommand: string;
}
```
The full installed component catalog stays available through `homarr://custom-widgets/components` and per-component resources. It is deliberately not embedded in the skill or clipboard prompt.
The `homarr://custom-widgets/skill` MCP resource returns the same `SKILL.md` and references once as bundled Markdown.
The component catalog is a compact index of names, categories, safety levels, shared prop names, policies, and example
IDs. Per-component results contain only component-specific props and a reference to shared prop documentation. Use
`customWidget_getSharedProps` once with only the shared prop names needed across the selected components. This avoids
repeating hundreds of global props in every tool result. The catalog is deliberately not embedded in the skill or
clipboard prompt; the skill response still contains the complete authoring, schema, runtime, and security references.
These interfaces do not return an archive URL or archive hash.
## Create a Custom Widget with MCP
1. Get the prompt, live schema, and component catalog.
2. Read the external API documentation.
3. Build one definition.
4. Validate the definition without saving it.
5. Create a preview session.
6. Test the queries and actions.
7. Open the preview URL with browser tools.
8. Inspect the redacted journal and revise the local candidate until it is valid.
9. Create the widget, including any source URLs and credentials the user already supplied.
10. Otherwise, ask the user to configure the saved definition with the ten-minute Homarr source-setup URL.
11. Create a final preview with the saved definition ID so Homarr loads its stored credentials, then test it again.
12. Update the saved definition if the final verification finds a problem.
13. Repeat the procedure for the next widget.
1. Load `customWidget_getSkill`, then get the prompt and live schema.
2. Call `customWidget_getComponentCatalog`, followed by `customWidget_getComponent` for only the relevant installed
components and `customWidget_getExample` when one catalog example matches the interaction pattern. If shared props
are needed, collect their catalog names and call `customWidget_getSharedProps` once for that set.
3. Read the external API documentation.
4. Build one definition, using `templateLines` for multiline JSX tool input.
5. Validate the definition without saving it and repair every reported issue.
6. Create a preview session.
7. Run `customWidget_previewQuery` for every query returned by the preview and inspect its actual status and data shape.
8. Test relevant actions in simulation mode.
9. Open the returned preview path with browser tools.
10. Inspect the redacted journal and revise the local candidate until validation, data, and rendering all work.
11. Create the widget, including any source URLs and credentials the user already supplied.
12. Open its returned management path, and use a structured question when the user must choose whether to place it on a board.
13. Otherwise, ask the user to configure the saved definition with the ten-minute Homarr source-setup URL.
14. Create a final preview with the saved definition ID so Homarr loads its stored credentials, then test it again.
15. Update the saved definition if the final verification finds a problem. `customWidget_update` also accepts
`templateLines` for multiline JSX without resetting omitted options.
16. Repeat the procedure for the next widget.
Successful validation returns a compact summary instead of echoing the full widget and template. Preview creation
returns its session, links, and query checklist without repeating the submitted definition. Keep the candidate locally
and reuse it for the next lifecycle step.
Each source-configuration URL applies to one definition or preview and one source. It is short-lived, single-use, and
can only be opened and completed by an authenticated Homarr administrator. The administrator confirms the server URL,
@@ -94,11 +113,51 @@ Homarr maintainers can run the same self-contained prompt that the workbench cop
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:ai
```
The suite uses DeepSeek V4 Pro to author Pokédex, Portainer, football, Jellyfin, and Home Assistant widgets. Before DeepSeek V4 Flash judges a result, it must pass Homarr's real clipboard parser, canonical schema, and deterministic scenario checks for the documented API routes, authentication, bindings, actions, and essential runtime components. It saves every response, validated widget manifest, and detailed category scores under `packages/custom-widgets/.ai-evaluations/`.
The manifest suite covers nine complex and public-API prompts. Its deterministic fixture-backed cases include a fake
homelab health API, CoinMarketCap's keyless public API, Bored, Agify, and a multi-request Pokédex. Before the configured
model judges a result, the candidate must pass Homarr's real clipboard parser, canonical schema, and deterministic
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
runtime components.
Each scenario stops as soon as it passes and has a hard limit of ten generation attempts. Override the models or
per-scenario limit with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and
To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run:
```sh
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
```
The same suites can target the Homarr provider with a Workshop PocketBase token instead of an OpenRouter key:
```sh
AI_PROVIDER_BASE_URL=https://homarr.dev/api/ai/v1 \
AI_PROVIDER_API_KEY=<workshop-token> \
AI_PROVIDER_MODEL=homarr/model \
pnpm --filter @homarr/custom-widgets test:assistant-ai
```
`AI_PROVIDER_JUDGE_MODEL` can select a separate judge when the configured endpoint offers one. The Homarr provider
offers exactly one model, so generation and review both use `homarr/model`.
The assistant test exposes the same lazy skill, live schema, component catalog, targeted component references,
validation, preview-query, and create-from-preview tools as the product. It refuses to save a definition that was not
validated exactly or whose preview queries were not all tested. Multi-request fixtures return a different payload for
each documented path and require manual-query parameters. Use `--case=pokedex`, `--case=coinmarketcap-keyless`,
`--case=bored-activity`, or `--case=agify-name` for a focused run.
Both suites use the configured provider models for generation and harsh review. They save tool traces, validated
manifests, raw reviews, and detailed category scores under
`packages/custom-widgets/.ai-evaluations/`. These live tests are maintainer-invoked rather than CI-required because
they consume a configured provider account and exercise a nondeterministic model. The repository's ordinary tests
cover the lifecycle enforcement, fixture routing, score calculation, and failure behavior without network access.
Each scenario stops as soon as it passes and has a hard limit of ten generation attempts. Override the endpoint,
models, or per-scenario limit with `AI_PROVIDER_BASE_URL`, `AI_PROVIDER_API_KEY`, `AI_PROVIDER_MODEL`,
`AI_PROVIDER_JUDGE_MODEL`, and
`CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result
into the bundled-widget source deliberately.
The reference API check uses a real PokeAPI list/detail request and a read-only Portainer container query when `PORTAINER_BASE_URL` and `PORTAINER_API_KEY` are set. Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --pokeapi-only` for the public PokeAPI check alone. To exercise start, restart, and stop against a disposable container, also set `PORTAINER_TEST_CONTAINER_ID` and the explicit safeguard `PORTAINER_ALLOW_MUTATIONS=1`; the script restores the container's original running state.
Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --public-ai-apis-only` to verify the live Bored and
CoinMarketCap response shapes used by the assistant fixtures. The same command also verifies Agify when
`AGIFY_API_KEY` is configured; otherwise it reports the authenticated check as skipped without weakening the
deterministic Agify fixture tests.
@@ -40,6 +40,12 @@ interface HomarrCustomWidgetV2 {
}
```
### Design with AI
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and, when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The assistant receives the widget schema, current non-secret configuration, and latest test response, then proposes the matching create or update tool for your approval.
Run **Test** first when possible so the prompt includes the endpoint's real response shape. Saved secrets are never added to the prompt. **Copy to clipboard** remains available when the Assistant is disabled or when you prefer another AI service.
<CustomWidgetCodeExample
id="complete-widget-manifest"
label="Complete manifest"
+81 -42
View File
@@ -8,10 +8,19 @@ tags:
# Model Context Protocol (MCP)
Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language.
Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language.
The endpoint implements the stateless MCP 2026-07-28 protocol and accepts 2025 Streamable HTTP clients through a
stateless compatibility path. It does not create protocol sessions or require sticky sessions. Homarr creates the
server and extracts its tools only after an authenticated request reaches the route. The previous `/api/mcp/mcp` URL
remains available as a compatibility alias, but new clients should always use `/api/mcp`.
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
approval before running mutating procedures.
## Getting Started
The MCP endpoint lives under the **API** page at `Management` → `Tools` → `API` → **MCP** tab.
@@ -42,7 +51,7 @@ Add the following to your client's MCP configuration file:
{
"mcpServers": {
"homarr": {
"url": "https://your-homarr-instance.com/api/mcp/mcp",
"url": "https://your-homarr-instance.com/api/mcp",
"headers": {
"ApiKey": "<your-api-key>"
}
@@ -73,7 +82,7 @@ Replace `<your-api-key>` with your actual API key. Never commit API keys to vers
"mcpServers": {
"homarr": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:<your-api-key>"]
"args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp", "--header", "ApiKey:<your-api-key>"]
}
}
}
@@ -96,6 +105,10 @@ Custom Widget and Workshop authoring are different: their tools, prompts, and re
key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an
API key never elevates the owner.
OAuth authorization and token requests bind issued credentials to the canonical `/api/mcp` resource. Dynamic client
registration remains available for compatibility with current MCP clients while the ecosystem transitions toward
pre-registered clients and Client ID Metadata Documents.
## Testing the Connection
Verify your MCP endpoint is working:
@@ -103,13 +116,17 @@ Verify your MCP endpoint is working:
```bash
curl -s -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Accept: application/json" \
-H "ApiKey: <your-api-key>" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' \
https://your-homarr-instance.com/api/mcp/mcp
-H "Mcp-Method: server/discover" \
-H "Mcp-Name: homarr" \
-d '{"jsonrpc":"2.0","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}},"id":1}' \
https://your-homarr-instance.com/api/mcp
```
A successful response includes the server capabilities and available tools. Use `tools/list` to see all available tools:
A successful response includes the supported protocol versions and server capabilities. Use `tools/list` to see all
available tools. MCP v2 clients add the per-request metadata and `Mcp-Method` / `Mcp-Name` headers automatically; the
abbreviated request below uses the stateless 2025 compatibility path for readability:
```bash
curl -s -X POST \
@@ -117,33 +134,36 @@ curl -s -X POST \
-H "Accept: application/json, text/event-stream" \
-H "ApiKey: <your-api-key>" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
https://your-homarr-instance.com/api/mcp/mcp
https://your-homarr-instance.com/api/mcp
```
Tool catalogs are returned in deterministic order and may be cached privately by the client for five minutes. The
removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`; use the single Streamable HTTP endpoint instead.
## Available Tools
Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** (GET, read-only) or **mutation** (POST, write action).
| Namespace | Example Tools | Type |
| ------------------ | --------------------------------------------------------- | ---------- |
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
| `apiKeys` | List API keys | GET |
| `beszel` | Systems status, alerts, historical system metrics | GET |
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
| `icon` | Search icons | GET |
| `info` | Server health, version info | GET |
| `integration` | List integrations, test connection, delete integration | GET + POST |
| `invite` | List invitations, delete invite | GET + POST |
| `calendar` | Calendar events from media servers | GET |
| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST |
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
| Namespace | Example Tools | Type |
| ------------------ | ---------------------------------------------------------- | ---------- |
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
| `apiKeys` | List API keys | GET |
| `beszel` | Systems status, alerts, historical system metrics | GET |
| `board` | List boards, read settings/CSS, create/update/delete board | GET + POST |
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
| `icon` | Search icons | GET |
| `info` | Server health, version info | GET |
| `integration` | List integrations, test connection, delete integration | GET + POST |
| `invite` | List invitations, delete invite | GET + POST |
| `calendar` | Calendar events from media servers | GET |
| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST |
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
The tools table on the MCP tab is dynamically generated - it always reflects the latest available procedures from your Homarr instance.
@@ -175,21 +195,36 @@ An agent creates one `homarr-custom-widget-v2` definition at a time. Start with
The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview,
secrets, publication, Workshop installation, and the HTTP authoring resources.
Then, read the live schema and component resources. These resources describe the installed Mantine components.
Load the installed skill before authoring. `customWidget_getSkill` returns the complete `SKILL.md` entrypoint and every
bundled reference in one response, so an MCP client does not need filesystem access or a second documentation fetch.
Then read the live schema and only the component resources required by the design. These resources describe the
installed Mantine components.
1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
2. Build one Custom Widget definition.
3. Call `customWidget_validate` without saving the definition.
4. Call `customWidget_previewCreate`.
5. Test named requests with `customWidget_previewQuery` and `customWidget_previewAction`.
6. Open the returned preview URL.
7. Examine `customWidget_previewJournal`.
8. Revise the local candidate and create another preview session until it is ready.
9. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided.
10. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion.
11. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again.
12. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections.
13. Repeat this procedure for the next Custom Widget.
1. Call `customWidget_getSkill`, `customWidget_getAuthoringPrompt`, `customWidget_schema`, and
`customWidget_getComponentCatalog`.
2. Fetch only the relevant installed component documentation with `customWidget_getComponent` and, when useful,
one interaction example with `customWidget_getExample`. Component results contain component-specific props; collect
any needed shared prop names from the compact catalog and fetch them together once with `customWidget_getSharedProps`.
3. Build one Custom Widget definition. Use `templateLines` for generated multiline JSX to avoid a fragile JSON-escaped
template string.
4. Call `customWidget_validate` without saving the definition and repair every returned issue path.
5. Call `customWidget_previewCreate`.
6. Call `customWidget_previewQuery` for every entry in the returned `queries` list. Supply the listed invocation
parameters for manual queries and inspect the real HTTP status and response shape before binding template fields.
7. Test relevant actions with `customWidget_previewAction` in simulation mode.
8. Open the returned `previewPath` or `previewUrl`.
9. Examine `customWidget_previewJournal`.
10. Revise the local candidate and create another preview session until validation, query data, actions, and rendering
are ready.
11. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided.
12. Open the returned `managementPath` to review the exact saved definition.
13. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion.
14. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again.
15. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections.
16. Repeat this procedure for the next Custom Widget.
Validation and preview creation return compact lifecycle metadata. They do not echo the submitted widget or duplicate
its template as both a string and line array; the agent should retain and reuse its local candidate.
The agent receives the configuration-request status. The agent does not receive plaintext credentials. A preview-scoped setup URL configures only that temporary preview; use a definition-scoped URL when the values must persist.
@@ -225,6 +260,10 @@ The Custom Widget tools are:
- `customWidget_workshopGet`
- `customWidget_workshopInstall`
- `customWidget_getAuthoringPrompt`
- `customWidget_getComponentCatalog`
- `customWidget_getComponent`
- `customWidget_getSharedProps`
- `customWidget_getExample`
- `customWidget_getSkill`
Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete
@@ -1,169 +0,0 @@
---
tags:
- Workshop
- Custom Widgets
- Custom CSS
---
# Community Workshop
:::warning Experimental feature
This feature is experimental and will probably change. If you encounter bugs, need more capabilities, or find something confusing, tell us in the [official discussion thread](https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets). Your feedback directly shapes what comes next. Happy hacking!
:::
The [Community Workshop](pathname:///workshop) is where Homarr users share and discover
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
you like, report abuse, and install entries directly into your own Homarr instance.
The central `homarr.dev` Workshop is a public community. Browsing it on the website is public. Browsing, installing,
publishing, or importing Workshop content **inside a Homarr instance is restricted to Homarr administrators**.
:::warning
The Workshop contains community-made content. Moderation is not a security boundary, and PocketBase does not interpret
or validate submission content. Always review widget capabilities, network origins, actions, and CSS before installing.
Homarr's installation-time validator is definitive and can reject content that the public service accepted earlier.
Back up your instance before installing untrusted content.
:::
## Browsing and voting
- Filter by **Widgets** or **CSS**, show or hide outdated entries, and sort by popularity, creation date, recent updates, or discussion activity.
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
- Open report counts are public on submission cards so visitors can make an informed installation decision. The submission author and Workshop moderators can read each report category and explanation; reporter identities remain moderator-only.
- Signed-in moderators see a **Moderation** button in the Workshop header. The moderation queue is sorted by open report count and lets moderators inspect every report, dismiss it, or delete the submission. Reports do not automatically remove a submission.
- The manual **Outdated** badge warns visitors that a submission may need an update.
## Comments
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop moderators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
## Submitting
1. Sign in with GitHub.
2. Click **Share yours** and choose the type:
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
It must match the `homarr-custom-widget-v2` schema (validated before submission).
- **Custom CSS** — paste your stylesheet (max 16384 characters).
3. Review the widget JSON or CSS in the syntax-aware editor, then add a title, description, optional screenshots, and changelog.
4. Submit the entry.
Custom widget JSON is normalized to readable multi-line formatting when it is published, and the Workshop detail page
also formats older compact submissions before displaying them.
Community members can report submissions as outdated or no longer working, malicious, spam, copyright-infringing,
inappropriate, or another concern. An outdated report does not automatically mark or remove the submission.
Submissions are published immediately. Reported entries are reviewed and may be removed.
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop moderator receives an email when a report is submitted. If a moderator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
:::info Separate roles
A Workshop moderator is a user with PocketBase's internal `isAdmin` field. This role moderates the central community
and is unrelated to a Homarr instance administrator.
:::
## Submission guidelines
Submissions must be useful to Homarr users and safe to install. Do not submit content that is
malicious, deceptive, illegal, hateful, sexually explicit, gore, gambling-focused, spam, or content
that primarily promotes unrelated products or services.
## Installing into Homarr
You can install workshop entries without leaving Homarr:
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Import from Workshop** in the
board widget picker. Choose **Install widget**, review the complete source and capabilities, then confirm API sources and any local secrets in the second modal step.
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Import from Workshop**, choose a theme, review its full CSS in the second same-size modal step, and click **Import CSS** once. The board setting is still saved separately.
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
Custom Widget imports detect required bearer, basic, header, or query credentials. You can enter them during import or explicitly defer them; secret values are never stored in Workshop content. Missing credentials remain visible on the local Custom Widget afterward.
## Workshop deployment
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
irreversible development reset.
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted
unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
links and email use the public host. Workshop stores only the OAuth provider username in `users.name`, and
users cannot rename it. Map **OAuth2 username** to `name`; leave OAuth2 id, full name, and avatar mappings
empty. Profile and avatar URLs are derived from `https://github.com/<username>` and
`https://github.com/<username>.png`, so PocketBase does not store duplicate profile metadata or avatar files. Configure
SMTP for comment, report, and removal notices.
The public URL contract is:
| Variable | Purpose | Default |
| ------------------------ | ------------------------------------------------- | ----------------------------- |
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. The
combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts;
the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it.
### Staging and demo deployment
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
```dotenv
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
WORKSHOP_API_URL=https://v2.preview.homarr.dev
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
PB_ALLOWED_ORIGINS=*
```
Start or update it from the repository root:
```sh
docker compose --env-file apps/workshop/.env.staging \
-f apps/workshop/docker-compose.staging.yml pull
docker compose --env-file apps/workshop/.env.staging \
-f apps/workshop/docker-compose.staging.yml up --detach
curl --fail http://127.0.0.1:8090/api/health
```
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
`/workshop` only on `WORKSHOP_WEB_URL`.
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
GitHub OAuth requires both credentials and the callback
`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart after changing environment values, then verify the health
endpoint, `/workshop`, a public listing request, GitHub sign-in, and an installation from the Homarr preview.
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
## Client architecture
Workshop access is centralized in `@homarr/workshop/backend`. It exports the typed `WorkshopBackend`, its typed PocketBase instance, stable TanStack Query option factories, and the `useWorkshop…` query and mutation hooks used by Homarr. The documentation site uses the same backend rather than maintaining a second API contract.
Listing fields derived by the `workshop_listings` view—such as submission type, score, and counts—are normalized by the shared client. Healthy deployments keep filtering, sorting, and pagination in PocketBase. If a persistent development volume still has an older view that rejects those fields, the client retries without them and applies the same operations locally; it can also fall back to the base submissions collection. Production deployments should nevertheless apply the current migration so the view exposes the complete metadata.
@@ -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
@@ -0,0 +1,59 @@
---
title: "Assistant"
description: "Have a complete assistant conversation directly on a board."
hide_title: true
---
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { assistantWidget } from ".";
<WidgetHeader widget={assistantWidget} categories={["Productivity"]} />
The Assistant widget brings the same conversation available from the user menu and command menu onto a
board. Messages, conversations, tool calls, approvals, files, mentions, model controls, reasoning controls,
usage details, and response actions remain synchronized with the app-wide assistant.
The assistant must be enabled by an administrator in **Management → Assistant**. Signed-out users and users
on an instance without an enabled model see an unavailable state instead of a broken composer.
### Adding the widget
<AddingWidget />
The default widget size is ten columns by four rows and automatically fits boards with fewer columns. Controls
and labels condense as the widget becomes narrower, while the conversation remains scrollable and usable on
touch devices. When a board layout does not provide enough height for conversation controls, the widget shows
a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height
in that layout to keep the complete conversation inline.
### Configuration
<WidgetConfig configuration={assistantWidget.configuration} />
**Current conversation** follows whichever conversation is active in the user-menu panel, `Shift + A`, or
the command menu. This is the default and keeps every assistant entry point synchronized.
**Pinned conversation** reveals a searchable conversation selector. A pinned widget never changes the
active conversation silently: select **Open conversation** inside the widget before continuing that thread.
### Conversation features
- Create, switch, rename, and delete conversations.
- Send text and supported file or image attachments by picker, drag and drop, or image paste.
- Mention permitted apps, integrations, boards, widgets, and tools with `@`.
- Use `/` shortcuts for common dashboard tasks.
- Review tool calls, approve mutations, and answer structured assistant questions inline.
- Answer pending structured questions from the dock above the composer without scrolling through the conversation.
- Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks.
- Edit user messages and copy, regenerate, branch, quote, export, or rate responses.
- Inspect syntax-highlighted Markdown, sources, generated files, tool results, streaming chain-of-thought,
cumulative conversation usage, per-turn token and context use, timing, speed, provider routing, and cost.
- Change the model and reasoning effort for the active conversation.
- Refresh the current board from the conversation header. Approved mutations also refresh visible board data automatically.
The expanded assistant and board widget use the same persisted thread runtime. A response continues in the
background when the widget leaves the viewport or the expanded panel is minimized. Each visible surface keeps an
independent draft, attachments, selection, and focus, so typing in a board widget cannot move or overwrite the
floating panel composer.
+25
View File
@@ -0,0 +1,25 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconRobot } from "@tabler/icons-react";
export const assistantWidget: WidgetDefinition = {
icon: IconRobot,
name: "Assistant",
description: "Have a complete assistant conversation directly on a board.",
path: "../../widgets/assistant",
configuration: {
items: [
{
name: "Conversation",
description: "Follow the active conversation or attach the widget to one persisted conversation.",
values: { type: "select", options: ["Current conversation", "Pinned conversation"] },
defaultValue: "Current conversation",
},
{
name: "Pinned conversation",
description: "Search for a conversation. This field is hidden when Current conversation is selected.",
values: { type: "string" },
defaultValue: "",
},
],
},
};
+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
+3 -1
View File
@@ -26,6 +26,8 @@ import { icalIntegration } from "@site/docs/integrations/ical";
The calendar widget shows a month view of events from different integrations.
It allows you to view calendar events or upcoming releases from your media managers.
Calendar requests use normal human month numbers (`1` for January through `12` for December), including when
the same data is requested through Homarr's MCP or Assistant tools.
### Screenshots
@@ -72,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
+5 -5
View File
@@ -43,12 +43,12 @@ import contextMenuImg from "./img/context-menu.png";
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — copy download names, or pause, resume, and delete when your account has Interact access and the client supports those actions
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category; open tooltips close when switching between compact and advanced mode
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume downloads from supported clients when your account has Interact access; read-only clients such as slskd do not expose these controls
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **Status indicators** — color-coded badges show download state at a glance
- **Speed overview** — total download and upload speeds displayed in the footer
@@ -89,13 +89,13 @@ import contextMenuImg from "./img/context-menu.png";
### Interactions
- **Right-click** any download to copy its name; pause/resume and delete require Interact access and client support
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
- **Click** any row to expand it and see full download details inline
- **Hover** a download name for a quick stats tooltip
- **Stats toggle** — show or hide the global stats bar from the footer
- **Pause / Resume** downloads from supported clients using the footer controls when you have Interact access; slskd remains read-only
- **Pause / Resume** all downloads using the footer controls
- **Filter** by integration or status using the filter button in the footer
- **Sort** the current view by clicking column headers to switch direction
- **Sort** by clicking column headers (with persistent sort direction)
<WidgetInteractionGuide />
+1 -1
View File
@@ -67,7 +67,7 @@ export const downloadsWidget: WidgetDefinition = {
{
name: "Hide completed torrent under this threshold (in kiB/s)",
description:
"This will hide completed torrent entries that have an upload speed below the specified threshold.",
"This will hide completed torrent entries that have a download speed below the specified threshold.",
values: "Any number above 0, 0 to disable",
defaultValue: "0",
},
@@ -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 />
+9
View File
@@ -0,0 +1,9 @@
{
"label": "Workshop",
"collapsed": true,
"position": 4,
"link": {
"type": "doc",
"id": "workshop/index"
}
}
+62
View File
@@ -0,0 +1,62 @@
---
title: Workshop development
sidebar_position: 4
tags:
- Workshop
- Developer
- Docusaurus
- PocketBase
---
# Workshop development
Install the repository dependencies and make sure Docker is available before starting the Workshop services. See the
[main development guide](../advanced/development/getting-started) for the repository prerequisites.
## Run PocketBase and documentation
Start PocketBase in one terminal:
```sh
cp apps/workshop/.env.example apps/workshop/.env
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
```
Then start Docusaurus in another terminal:
```sh
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
```
PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume.
Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at
`http://127.0.0.1:3003/workshop`.
Point Homarr at the local services with:
```dotenv
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
WORKSHOP_API_URL=http://127.0.0.1:8090
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
```
Restart `pnpm dev:docs` after changing the Docusaurus URL variables. If Homarr is also running, restart its `pnpm dev`
process after changing the Workshop URLs. GitHub OAuth is optional locally. See
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
## URL testing
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr also reads
`WORKSHOP_WEB_URL` when its server starts, so these values do not rely on `NEXT_PUBLIC_*` build variables. Test a
separate API origin before release:
```sh
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
WORKSHOP_API_URL=http://127.0.0.1:8090 \
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
pnpm dev:docs
```
The [Workshop operator guide](./operator) covers production environment variables, staging, and service validation.
@@ -0,0 +1,54 @@
---
title: Homarr provider
sidebar_position: 2
tags:
- Assistant
- AI
- Privacy
- Community
---
import { OpenCollectiveLink } from "@site/src/components/assistant-provider/open-collective-link";
# Homarr provider
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
1. Open **Management → Assistant**.
2. Select **Homarr** as the provider.
3. Save the provider connection.
4. Select the available model, enable the assistant, and save.
5. Open the assistant and sign in to the Workshop when asked.
## Daily allowance
- 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. The Workshop server enforces the
limit before it sends a request to OpenRouter.
## Data and cost
The Workshop keeps the OpenRouter API key on its server. It does not send that key to Homarr or the browser.
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 Homarr community pays for these requests. Donations help cover the cost.
<OpenCollectiveLink />
Workshop operators can change the upstream model, per-user allowance, global allowance, and security limits in the
[Workshop operator guide](./operator#homarr-provider-proxy).
+53
View File
@@ -0,0 +1,53 @@
---
title: Community Workshop
sidebar_label: Overview
tags:
- Workshop
- Custom Widgets
- Custom CSS
---
# Community Workshop
The [Community Workshop](pathname:///workshop) is where Homarr users share Custom Widgets and dashboard CSS.
:::warning Experimental
Workshop content is made by the community. Review it before installation and back up your Homarr instance.
:::
## Find and install content
Browse the Workshop by type, date, popularity, or recent activity. Each submission includes its source, screenshots,
author, revision, comments, and reports.
Administrators can install content without leaving Homarr:
- **Custom Widgets:** open **Management → Custom Widgets**, then select **Import from Workshop**.
- **Custom CSS:** open a board's **Settings → Custom CSS**, then select **Import from Workshop**.
Homarr shows the complete source and required permissions before installation. Widget credentials are entered in your
Homarr instance and are never included in a Workshop submission.
## Share your work
1. Sign in to the Workshop with GitHub.
2. Select **Share yours**.
3. Choose **Custom Widget** or **Custom CSS**.
4. Add the source, a short description, screenshots, and a changelog.
5. Review and publish the submission.
Export Custom Widget JSON from **Management → Custom Widgets → Export**. You can edit a submission later without
changing its Workshop URL.
## Voting, comments, and reports
Signed-in users can vote, comment, and report submissions. Authors can edit their own comments and submissions.
Workshop moderators can review reports, remove comments, and delete unsafe content. Reports do not remove a submission
automatically.
A Workshop moderator manages the public community. This is separate from the administrator role on a Homarr instance.
Deployment and server configuration are in the [Workshop operator guide](./operator). Local setup is in
[Workshop development](./development).
+144
View File
@@ -0,0 +1,144 @@
---
title: Workshop operator guide
sidebar_label: Operator guide
sidebar_position: 3
tags:
- Workshop
- Deployment
- Environment variables
- PocketBase
---
# Workshop operator guide
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site
and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile,
Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first
uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
irreversible development reset. See [Workshop development](./development) for the complete local setup.
## Authentication and notifications
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins
unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
links and email use the public host. Workshop stores only the OAuth provider username in `users.name`, and users cannot
rename it. Map **OAuth2 username** to `name`; leave OAuth2 id, full name, and avatar mappings empty. Profile and avatar
URLs are derived from `https://github.com/<username>` and `https://github.com/<username>.png`, so PocketBase does not
store duplicate profile metadata or avatar files. Configure SMTP for comment, report, and removal notices.
## Environment variables
The public URL contract and Workshop-only server configuration are:
| Variable | Purpose | Default |
| -------------------------------------- | ------------------------------------------------- | ------------------------------ |
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty |
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Per-user daily provider allowance | `50` |
| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Shared daily ceiling across all users | `10000` |
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` |
| `HOMARR_AI_OPENROUTER_MODEL` | Server-selected upstream model | Selected by the Homarr team |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts. The deprecated docs-only `WORKSHOP_URL` alias remains for one
release; `WORKSHOP_API_URL` takes precedence. The combined Workshop image writes the public URLs into its browser
runtime configuration whenever the container starts; the Docker build arguments remain fallbacks. Restart the
container after changing these values, without rebuilding it.
### Homarr provider proxy
The [Homarr provider](./homarr-provider) proxy is part of the Workshop image. `OPENROUTER_API_KEY` enables the proxy,
`HOMARR_AI_OPENROUTER_BASE_URL` selects its OpenRouter-compatible upstream, `HOMARR_AI_OPENROUTER_MODEL` selects the
private upstream model behind the stable `homarr/model` alias, and the two allowance variables provide per-user and
shared request ceilings. Lowering either allowance takes effect for existing users. The upstream URL cannot contain
embedded credentials, a query, or a fragment and must use HTTPS. Tests can explicitly opt into an insecure local mock
with `HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true`; do not use that setting with a real API key.
The proxy overwrites client routing and generation limits. It requires OpenRouter zero-data-retention routing, denies
data-collecting providers, limits responses to 32,768 completion tokens, and does not retain prompts, responses, or
per-request telemetry. It accepts only uploaded image data, conservatively bounds text to the advertised context, and
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
limit as the final monetary backstop because request quotas are not dollar accounting.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
An operator can explicitly smoke-test a deployed Workshop against its real configured upstream with a dedicated,
low-quota test user:
```sh
HOMARR_AI_LIVE_SMOKE=1 \
WORKSHOP_LIVE_TEST_URL=https://workshop.example.com \
WORKSHOP_LIVE_TEST_TOKEN=<temporary-pocketbase-user-token> \
pnpm test:workshop-live-provider
```
This optional check consumes one provider request. The test client needs neither the OpenRouter key nor the private
upstream model ID. Do not run it in CI or with a normal user's Workshop token.
The Custom Widget AI tests can use this proxy. Set `AI_PROVIDER_BASE_URL` to the `/api/ai/v1` endpoint and
`AI_PROVIDER_API_KEY` to a temporary Workshop user token. See
[Agent authoring](../management/custom-widgets/agent-authoring#evaluate-the-copied-prompt) for the command.
## Staging and demo deployment
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
```dotenv
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
WORKSHOP_API_URL=https://v2.preview.homarr.dev
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
PB_ALLOWED_ORIGINS=*
```
Start or update it from the repository root:
```sh
docker compose --env-file apps/workshop/.env.staging \
-f apps/workshop/docker-compose.staging.yml pull
docker compose --env-file apps/workshop/.env.staging \
-f apps/workshop/docker-compose.staging.yml up --detach
curl --fail http://127.0.0.1:8090/api/health
```
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
`/workshop` only on `WORKSHOP_WEB_URL`.
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
GitHub OAuth requires both credentials and the callback `https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart
after changing environment values, then verify the health endpoint, `/workshop`, a public listing request, GitHub
sign-in, and an installation from the Homarr preview.
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
## Client architecture
Workshop access is centralized in `@homarr/workshop/backend`. It exports the typed `WorkshopBackend`, its typed
PocketBase instance, stable TanStack Query option factories, and the `useWorkshop…` query and mutation hooks used by
Homarr. The documentation site uses the same backend rather than maintaining a second API contract.
Listing fields derived by the `workshop_listings` view—such as submission type, score, and counts—are normalized by the
shared client. Healthy deployments keep filtering, sorting, and pagination in PocketBase. If a persistent development
volume still has an older view that rejects those fields, the client retries without them and applies the same
operations locally; it can also fall back to the base submissions collection. Production deployments should
nevertheless apply the current migration so the view exposes the complete metadata.
+1 -1
View File
@@ -362,7 +362,7 @@ html[data-workshop-detail-loading] #workshop-detail-route-fallback {
box-sizing: border-box;
color: #4b5563;
display: flex;
font: 500 0.875rem/1.5 Inter, system-ui, sans-serif;
font: 500 0.875rem/1.5 "Work Sans", system-ui, sans-serif;
justify-content: center;
min-height: 100vh;
padding: 2rem;
-1
View File
@@ -51,7 +51,6 @@
"postcss": "^8.5.16",
"posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1",
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-day-picker": "catalog:",
"react-dom": "catalog:",
@@ -0,0 +1,41 @@
.link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.5rem;
background: var(--ifm-background-surface-color);
color: var(--ifm-font-color-base);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
text-decoration: none;
transition:
background-color 150ms ease,
border-color 150ms ease;
}
.link:hover {
border-color: var(--ifm-color-emphasis-500);
background: var(--ifm-color-emphasis-100);
color: var(--ifm-font-color-base);
text-decoration: none;
}
.link:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
.logo {
width: 1.1rem;
height: 1.1rem;
flex: 0 0 auto;
}
@media (prefers-reduced-motion: reduce) {
.link {
transition: none;
}
}
@@ -0,0 +1,10 @@
import Translate from "@docusaurus/Translate";
import styles from "./open-collective-link.module.css";
export const OpenCollectiveLink = () => (
<a className={styles.link} href="https://opencollective.com/homarr">
<img className={styles.logo} src="/img/opencollective.svg" alt="" aria-hidden />
<Translate id="workshop.provider.donate">Donate to Homarr on OpenCollective</Translate>
</a>
);
@@ -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>
);
@@ -1,22 +1,100 @@
import { translate } from "@docusaurus/Translate";
import { IconBrandDocker, IconCpu, IconDeviceDesktopAnalytics } from "@tabler/icons-react";
import styles from "../../../../pages/index.module.css";
import { AppWidget } from "./widgets/app-widget";
import { ClockWidget } from "./widgets/clock-widget";
import { DownloadsWidget } from "./widgets/downloads-widget";
import { WeatherWidget } from "./widgets/weather-widget";
import { StockWidget } from "./widgets/stock-widget";
import { EntityStateWidget } from "./widgets/entity-state-widget";
import { StockWidget } from "./widgets/stock-widget";
import { WeatherWidget } from "./widgets/weather-widget";
import { WidgetCard } from "./widgets/card";
export const HeroCards = () => {
return (
<div className="argos-ignore hero-cards flex flex-wrap max-w-[504px] gap-y-4 gap-x-3 text-gray-700 dark:text-gray-300">
<div className={`argos-ignore hero-cards ${styles.heroCards}`}>
<StockWidget />
<AppWidget />
<AppWidget className="hidden 3xl:block" />
<WeatherWidget />
<DownloadsWidget />
<EntityStateWidget />
<AppWidget />
<AppWidget name="Plex" />
<AppWidget name="Jellyfin" />
<AppWidget name="Homeassistant" label="Home Assistant" />
<ClockWidget />
<SystemResourcesWidget />
<WeatherWidget />
<EntityStateWidget />
<AppWidget name="PiHole" label="Pi-hole" />
<AppWidget name="qBittorrent" />
<DownloadsWidget />
<DockerWidget />
<AppWidget name="Sonarr" />
<AppWidget name="Radarr" />
</div>
);
};
const heroLabels = {
systemResources: translate({ id: "homepage.preview.systemResources", message: "System resources" }),
online: translate({ id: "homepage.preview.online", message: "Online" }),
cpu: translate({ id: "homepage.preview.cpu", message: "CPU" }),
memory: translate({ id: "homepage.preview.memory", message: "Memory" }),
disk: translate({ id: "homepage.preview.disk", message: "Disk" }),
docker: translate({ id: "homepage.preview.docker", message: "Docker" }),
dockerSummary: translate({ id: "homepage.preview.dockerSummary", message: "2 running · 1 failed" }),
healthy: translate({ id: "homepage.preview.healthy", message: "Healthy" }),
running: translate({ id: "homepage.preview.running", message: "Running" }),
exited: translate({ id: "homepage.preview.exited", message: "Exited" }),
};
const SystemResourcesWidget = () => (
<WidgetCard width={2} className={styles.resourcesWidget}>
<div className={styles.widgetHeading}>
<span className={styles.widgetTitle}>
<IconDeviceDesktopAnalytics size={17} aria-hidden="true" />
{heroLabels.systemResources}
</span>
<span className={styles.onlineStatus}>{heroLabels.online}</span>
</div>
<ResourceMeter icon={<IconCpu size={15} aria-hidden="true" />} label={heroLabels.cpu} value={34} />
<ResourceMeter label={heroLabels.memory} value={62} />
<ResourceMeter label={heroLabels.disk} value={98} />
</WidgetCard>
);
const ResourceMeter = ({ icon, label, value }: { icon?: React.ReactNode; label: string; value: number }) => (
<div className={`${styles.resourceMeter} ${value >= 90 ? styles.resourceMeterCritical : ""}`}>
<div className={styles.resourceLabel}>
<span>
{icon}
{label}
</span>
<span>{value}%</span>
</div>
<progress className={styles.resourceTrack} aria-label={`${label} usage`} max={100} value={value} />
</div>
);
const DockerWidget = () => (
<WidgetCard width={2} className={styles.dockerWidget}>
<div className={styles.widgetHeading}>
<span className={styles.widgetTitle}>
<IconBrandDocker size={18} aria-hidden="true" />
{heroLabels.docker}
</span>
<span className={styles.dockerCount}>{heroLabels.dockerSummary}</span>
</div>
<div className={styles.containerList}>
{[
{ name: "immich", status: heroLabels.healthy, failed: false },
{ name: "jellyfin", status: heroLabels.running, failed: false },
{ name: "paperless", status: heroLabels.exited, failed: true },
].map(({ name, status, failed }) => (
<div className={`${styles.containerRow} ${failed ? styles.containerRowFailed : ""}`} key={name}>
<span className={styles.containerName}>{name}</span>
<span className={styles.containerStatus}>
<span className={`${styles.statusDot} ${failed ? styles.statusDotFailed : ""}`} aria-hidden="true" />
{status}
</span>
</div>
))}
</div>
</WidgetCard>
);
@@ -1,4 +1,5 @@
import Link from "@docusaurus/Link";
import { translate } from "@docusaurus/Translate";
import { IconDownload, IconExternalLink } from "@tabler/icons-react";
import clsx from "clsx";
import styles from "../../../../pages/index.module.css";
@@ -7,9 +8,9 @@ import { HeroCards } from "./hero-cards";
export default function HomeHero() {
return (
<header className={clsx("hero hero--primary", styles.heroBanner, styles.hero)}>
<div className="flex">
<div className="ps-0 lg:ps-52">
<h1 className="font-extrabold lg:text-7xl text-3xl">
<div className={styles.heroContent}>
<div className={styles.heroCopy}>
<h1 className={clsx("font-extrabold lg:text-7xl text-3xl", styles.heroTitle)}>
A simple, yet
<br />
powerful dashboard
@@ -22,7 +23,7 @@ export default function HomeHero() {
information.
</p>
<div className={"flex flex-nowrap gap-2"}>
<div className={styles.heroActions}>
<Link
data-attr="Install button"
className={"button button--secondary button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800"}
@@ -57,9 +58,12 @@ export default function HomeHero() {
</Link>
</div>
</div>
<div className="flex-grow-1 w-full lg:block hidden hover-animation">
<section
className={clsx(styles.heroPreview, "hover-animation")}
aria-label={translate({ id: "homepage.preview.label", message: "Example Homarr dashboard" })}
>
<HeroCards />
</div>
</section>
</div>
</header>
);
@@ -1,20 +1,21 @@
import { supportedIntegrations } from "@site/src/constants/supported-integrations";
import { CommonWidgetProps, WidgetCard } from "./card";
import { getRndInteger } from "@site/src/tools/math";
import clsx from "clsx";
export const AppWidget = ({ className }: CommonWidgetProps) => {
const randomApp = supportedIntegrations[getRndInteger(0, supportedIntegrations.length - 1)];
interface AppWidgetProps extends CommonWidgetProps {
name: string;
label?: string;
}
export const AppWidget = ({ name, label, className }: AppWidgetProps) => {
const app = supportedIntegrations.find((integration) => integration.name === name);
if (!app) return null;
return (
<WidgetCard width={1} className={clsx("text-center", className)}>
<span className={"text-sm font-bold"}>{randomApp.name}</span>
<img
src={randomApp.iconUrl}
className="aspect-square scale-[0.6] transition-transform"
alt={`${randomApp.name} Icon`}
/>
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2"></div>
<WidgetCard width={1} className={clsx("app-card text-center", className)}>
<span className={"text-sm font-bold"}>{label ?? app.name}</span>
<img src={app.iconUrl} className="aspect-square scale-[0.6]" alt="" />
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2" aria-hidden="true" />
</WidgetCard>
);
};
@@ -1,5 +1,6 @@
import clsx from "clsx";
import { ReactNode } from "react";
import styles from "../../../../../pages/index.module.css";
export interface CommonWidgetProps {
className?: string;
@@ -16,15 +17,13 @@ export const WidgetCard = ({ width, children, className, onClick }: WidgetCardPr
return (
<Component
className={clsx(
"rounded-xl flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
styles.widgetCard,
"flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
className,
onClick ? "cursor-pointer" : undefined,
)}
style={{
width: (128 * width + (width === 2 ? 16 : 0)) * 0.9,
height: 128,
background: "rgb(from var(--ifm-navbar-background-color) r g b / 80%)",
}}
style={{ width: (128 * width + (width === 2 ? 16 : 0)) * 0.9, height: 128 }}
type={onClick ? "button" : undefined}
onClick={onClick}
>
{children}
@@ -1,26 +1,40 @@
import { getRndInteger } from "@site/src/tools/math";
import { translate } from "@docusaurus/Translate";
import { useEffect, useState } from "react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
import { generateSlug } from "random-word-slugs";
import styles from "../../../../../pages/index.module.css";
const downloadQueue = ["family-photos.zip", "linux-isos.mkv", "server-backup.zip", "holiday-video.mp4"];
const downloadLabels = {
file: translate({ id: "homepage.preview.file", message: "File" }),
progress: translate({ id: "homepage.preview.progress", message: "Progress" }),
};
export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
const [downloads, setDownloads] = useState<Download[]>([]);
const [downloads, setDownloads] = useState<Download[]>([
{ filename: "photos-backup.zip", progress: 72 },
{ filename: "media-library.mkv", progress: 38 },
]);
useEffect(() => {
const intervalId = setInterval(() => {
setDownloads((prev: Download[]) => {
const filteredPrev = prev.filter((download) => download.progress !== 100);
filteredPrev.forEach((download, index) => {
download.progress = Math.min(100, download.progress + getRndInteger(0, index < 2 ? 16 : 8));
});
const progressedDownloads = filteredPrev.map((download, index) => ({
...download,
progress: Math.min(100, download.progress + (index < 2 ? 8 : 4)),
}));
if (filteredPrev.length < 3 && filteredPrev.length === prev.length) {
filteredPrev.push(generateRandomDownload());
if (progressedDownloads.length < 3 && progressedDownloads.length === prev.length) {
const availableDownloads = downloadQueue.filter(
(filename) => !progressedDownloads.some((download) => download.filename === filename),
);
const filename = availableDownloads[0];
if (filename) progressedDownloads.push({ filename, progress: 0 });
}
return [...filteredPrev];
return progressedDownloads;
});
}, 500);
@@ -34,19 +48,31 @@ export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
<table className="mb-0 w-full h-full text-xs">
<thead className="inline-block w-full">
<tr className="inline-block w-full">
<th className="border-none px-2 py-1 text-start inline-block w-1/2">File</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">Progress</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.file}</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.progress}</th>
</tr>
</thead>
<tbody>
{downloads.map((file: Download, index: number) => {
{downloads.map((file: Download) => {
return (
<tr key={index}>
<tr key={file.filename}>
<td className="border-none text-nowrap p-2">{file.filename}</td>
<td className="border-none p-2 w-full">
<div className="overflow-hidden rounded-xl h-2 w-full bg-zinc-600">
<div className="bg-green-600 h-full animated-width" style={{ width: `${file.progress}%` }}></div>
</div>
<td
className="border-none p-2 w-full"
aria-label={translate(
{ id: "homepage.preview.downloadComplete", message: "{progress}% complete" },
{ progress: file.progress },
)}
>
<progress
className={styles.downloadProgress}
value={file.progress}
max={100}
aria-label={translate(
{ id: "homepage.preview.downloadProgress", message: "{filename} download progress" },
{ filename: file.filename },
)}
/>
</td>
</tr>
);
@@ -61,15 +87,3 @@ interface Download {
filename: string;
progress: number;
}
const formats = ["mkv", "mp4"];
const generateRandomDownload = (): Download => {
const randomMovie = generateSlug(2);
const randomFormat = formats[getRndInteger(0, formats.length - 1)];
return {
filename: `${randomMovie}.${randomFormat}`,
progress: 0,
};
};
@@ -27,6 +27,7 @@ export const StockWidget = ({ className }: CommonWidgetProps) => {
HOMR
</div>
<Line
ariaLabel="Simulated HOMR stock price trend"
height={128}
width={244}
colors={[upwardTrend ? "green" : "red"]}
@@ -758,7 +758,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
</div>
</div>
<Link
to="/docs/management/workshop/"
to="/docs/workshop/"
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Installation guide <IconExternalLink size={13} />
+8 -4
View File
@@ -1,4 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
@import "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";
@import "tailwindcss/theme" layer(theme);
@import "tailwindcss/utilities" layer(utilities);
@@ -10,7 +10,7 @@
--breakpoint-3xl: 1697px;
--font-heading: var(--font-sans);
--font-sans: "Inter", sans-serif;
--font-sans: "Work Sans", sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -137,7 +137,7 @@
--sidebar-border: oklch(0.9276 0.0058 264.5313);
--sidebar-ring: oklch(0.56 0.205 25.6509);
--destructive-foreground: oklch(0.9851 0 0);
--font-sans: "Inter", sans-serif;
--font-sans: "Work Sans", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
@@ -491,6 +491,10 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
}
@media (prefers-reduced-motion: reduce) {
.hover-animation {
animation: none;
}
.submit-step-enter {
animation: none;
}
@@ -541,7 +545,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
--sidebar-accent-foreground: oklch(0.6646 0.2222 25.6509);
--sidebar-border: oklch(0.2739 0.0055 286.0326);
--sidebar-ring: oklch(0.72 0.16 25.6509);
--font-sans: "Inter", sans-serif;
--font-sans: "Work Sans", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
+448 -4
View File
@@ -28,13 +28,457 @@ section {
}
.heroBanner {
padding: 4rem;
padding: clamp(3rem, 6vw, 6rem) clamp(1.25rem, 4vw, 4rem);
position: relative;
overflow: hidden;
}
@media screen and (max-width: 996px) {
.heroBanner {
padding: 2rem;
.heroContent {
display: grid;
grid-template-columns: minmax(25rem, 1fr) minmax(44rem, 48rem);
align-items: center;
gap: clamp(1rem, 3vw, 3rem);
max-width: 108rem;
margin-inline: auto;
}
.heroCopy {
max-width: 52rem;
}
.heroTitle {
text-wrap: balance;
}
.heroActions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.heroPreview {
min-width: 0;
}
.heroCards {
display: flex;
flex-wrap: wrap;
gap: 1rem 0.75rem;
width: min(100%, 48rem);
margin-inline: auto;
color: #343a40;
}
:global([data-theme="dark"]) .heroCards {
color: #dee2e6;
}
.widgetCard {
flex: none;
min-width: 0;
border-radius: 0.75rem;
border: 1px solid color-mix(in srgb, currentcolor 12%, transparent) !important;
color: inherit;
font: inherit;
background: color-mix(in srgb, var(--ifm-navbar-background-color) 76%, transparent) !important;
backdrop-filter: blur(12px);
box-shadow: 0 12px 28px rgb(31 41 55 / 14%);
}
.resourcesWidget,
.dockerWidget {
align-items: stretch !important;
justify-content: flex-start !important;
gap: 0.5rem;
padding: 0.75rem !important;
}
.widgetHeading,
.resourceLabel,
.containerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.widgetTitle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
font-size: 0.8125rem;
font-weight: 700;
}
.onlineStatus,
.dockerCount {
flex: none;
font-size: 0.6875rem;
font-weight: 600;
}
.onlineStatus {
color: #087f5b;
}
:global([data-theme="dark"]) .onlineStatus {
color: #69dbb1;
}
.resourceMeter {
display: grid;
gap: 0.25rem;
}
.resourceMeterCritical .resourceLabel {
color: #a51111;
font-weight: 700;
}
.resourceLabel {
font-size: 0.6875rem;
}
.resourceLabel > span:first-child {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.resourceTrack {
display: block;
appearance: none;
overflow: hidden;
width: 100%;
height: 0.375rem;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, currentcolor 16%, transparent);
}
.resourceTrack::-webkit-progress-bar {
background: transparent;
}
.resourceTrack::-webkit-progress-value,
.resourceTrack::-moz-progress-bar {
height: 100%;
border-radius: inherit;
background: var(--ifm-color-primary);
}
.resourceMeterCritical .resourceTrack::-webkit-progress-value,
.resourceMeterCritical .resourceTrack::-moz-progress-bar {
background: #e03131;
}
.downloadProgress {
display: block;
appearance: none;
overflow: hidden;
width: 100%;
height: 0.5rem;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, currentcolor 18%, transparent);
}
.downloadProgress::-webkit-progress-bar {
background: transparent;
}
.downloadProgress::-webkit-progress-value,
.downloadProgress::-moz-progress-bar {
border-radius: inherit;
background: #2f9e44;
}
.containerList {
display: grid;
gap: 0.25rem;
}
.containerRow {
font-size: 0.6875rem;
line-height: 1.25;
}
.containerName {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.containerStatus {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: color-mix(in srgb, currentcolor 82%, transparent);
}
.statusDot {
width: 0.375rem;
height: 0.375rem;
border-radius: 999px;
background: #12b886;
}
.containerRowFailed {
color: #a51111;
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;
}
.statusDotFailed {
background: #fa5252;
animation: failed-container-pulse 1.4s ease-in-out infinite;
}
@keyframes failed-container-pulse {
50% {
opacity: 0.35;
transform: scale(1.65);
}
}
@media screen and (max-width: 1280px) {
.heroBanner {
padding: 3rem 1.5rem;
}
.heroContent {
grid-template-columns: minmax(0, 1fr);
gap: 2.5rem;
}
.heroCopy {
max-width: 46rem;
}
.heroCards {
width: min(100%, 48rem);
}
}
@media screen and (max-width: 996px) {
.heroBanner {
padding: 2.5rem 1rem;
}
.heroPreview {
display: none;
}
.heroActions > :global(*) {
flex: 1 1 auto;
}
}
@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>
-3
View File
@@ -1,3 +0,0 @@
export function getRndInteger(min: number, max: number) {
return Math.round(Math.random() * (max - min)) + min;
}
+2
View File
@@ -2152,6 +2152,7 @@
"beszelSystemStats",
"traefik",
"customApi",
"assistant",
"wud"
]
},
@@ -2165,6 +2166,7 @@
},
"integrationIds": {
"default": [],
"maxItems": 32,
"type": "array",
"items": {
"type": "string"
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>OpenCollective</title>
<path
fill="#7FADF2"
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12c2.54 0 4.894-.79 6.834-2.135l-3.107-3.109a7.715 7.715 0 1 1 0-13.512l3.107-3.109A11.943 11.943 0 0 0 12 0zm9.865 5.166-3.109 3.107A7.67 7.67 0 0 1 19.715 12a7.682 7.682 0 0 1-.959 3.727l3.109 3.107A11.943 11.943 0 0 0 24 12c0-2.54-.79-4.894-2.135-6.834z"
/>
</svg>

After

Width:  |  Height:  |  Size: 447 B

+14 -4
View File
@@ -14,6 +14,12 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@ai-sdk/openai-compatible": "catalog:",
"@ai-sdk/react": "catalog:",
"@assistant-ui/react": "catalog:",
"@assistant-ui/react-ai-sdk": "catalog:",
"@assistant-ui/react-lexical": "catalog:",
"@assistant-ui/react-markdown": "catalog:",
"@dnd-kit/abstract": "catalog:",
"@dnd-kit/collision": "catalog:",
"@dnd-kit/core": "catalog:",
@@ -62,7 +68,7 @@
"@mantine/hooks": "catalog:",
"@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-devtools": "catalog:",
@@ -77,6 +83,8 @@
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
"adm-zip": "catalog:",
"ai": "catalog:",
"assistant-stream": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"better-sqlite3": "catalog:",
"chroma-js": "catalog:",
@@ -89,10 +97,9 @@
"isomorphic-dompurify": "catalog:",
"jotai": "catalog:",
"jszip": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"mcp-handler": "catalog:",
"mermaid": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
"posthog-js": "catalog:",
@@ -100,11 +107,14 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-error-boundary": "catalog:",
"react-shiki": "catalog:",
"react-simple-code-editor": "catalog:",
"remark-breaks": "catalog:",
"remark-directive": "catalog:",
"remark-gfm": "catalog:",
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"use-deep-compare-effect": "catalog:",
"yaml": "catalog:",
"zod": "catalog:"
@@ -22,6 +22,7 @@ export async function GET() {
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:tools"],
resource_indicators_supported: true,
},
{ headers: corsHeaders },
);
@@ -13,7 +13,7 @@ export async function GET() {
return Response.json(
{
resource: baseUrl,
resource: `${baseUrl}/api/mcp`,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useDisclosure, useMounted } from "@mantine/hooks";
import { z } from "zod/v4";
import { signIn } from "@homarr/auth/client";
@@ -32,6 +32,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
const searchParams = useSearchParams();
const isError = searchParams.has("error");
const router = useRouter();
const isMounted = useMounted();
const [isPending, setIsPending] = useState(false);
const form = useZodForm(extendedValidation, {
initialValues: {
@@ -132,7 +133,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
{providers.includes("credentials") && (
<Stack gap="sm">
<SubmitButton isPending={isPending} form={form} provider="credentials">
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="credentials">
{t("action.login.label")}
</SubmitButton>
<PasswordForgottenCollapse username={form.values.name} />
@@ -140,7 +141,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
)}
{providers.includes("ldap") && (
<SubmitButton isPending={isPending} form={form} provider="ldap">
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="ldap">
{t("action.login.labelWith", { provider: "LDAP" })}
</SubmitButton>
)}
@@ -151,7 +152,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
)}
{providers.includes("oidc") && (
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
<Button fullWidth variant="light" disabled={!isMounted} onClick={async () => await signInAsync("oidc")}>
{t("action.login.labelWith", { provider: oidcClientName })}
</Button>
)}
@@ -161,12 +162,13 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
};
interface SubmitButtonProps {
isMounted: boolean;
isPending: boolean;
form: UseFormReturnType<FormType>;
provider: "credentials" | "ldap";
}
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
const isCurrentProviderActive = form.getValues().provider === provider;
return (
@@ -176,7 +178,7 @@ const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren
fullWidth
onClick={() => form.setFieldValue("provider", provider)}
loading={isPending && isCurrentProviderActive}
disabled={isPending && !isCurrentProviderActive}
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
>
{children}
</Button>
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { cache } from "react";
import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
@@ -43,8 +44,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
page: async ({ params }: { params: Promise<TParams> }) => {
const resolvedParams = await params;
const queryClient = getQueryClient();
const session = await auth();
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const board = await getInitialBoard(resolvedParams).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
redirect("/auth/login");
}
notFound();
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
notFound();
}
throw error;
});
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -96,8 +113,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
};
} catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
// Ignore not found and bad-request errors and return empty metadata
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
return {};
}
@@ -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;
@@ -1,107 +0,0 @@
import { createElement, act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BoardProviders } from "./_providers";
const mocks = vi.hoisted(() => ({
searchParams: new URLSearchParams(),
layoutOverrideId: undefined as string | null | undefined,
initialOpen: undefined as boolean | undefined,
}));
vi.mock("next/navigation", () => ({
useSearchParams: () => mocks.searchParams,
}));
vi.mock("@homarr/boards/context", async () => {
const React = await import("react");
return {
BoardProvider: ({ children, layoutOverrideId }: { children: React.ReactNode; layoutOverrideId: string | null }) => {
mocks.layoutOverrideId = layoutOverrideId;
return React.createElement(React.Fragment, null, children);
},
};
});
vi.mock("@homarr/boards/edit-mode", async () => {
const React = await import("react");
return {
EditModeProvider: ({ children, initialOpen }: { children: React.ReactNode; initialOpen: boolean }) => {
mocks.initialOpen = initialOpen;
return React.createElement(React.Fragment, null, children);
},
};
});
describe("board providers", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
mocks.layoutOverrideId = undefined;
mocks.initialOpen = undefined;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test.each([
["base", "base"],
["custom", "custom"],
])("uses the requested %s layout for an explicit edit request", (_name, requestedLayoutId) => {
mocks.searchParams = new URLSearchParams(`layout=${requestedLayoutId}&edit=true&returnTo=settings`);
act(() => {
root.render(
createElement(
BoardProviders,
{
initialBoard: { layouts: [{ id: "mobile" }, { id: "custom" }, { id: "base" }] } as never,
initialLayoutId: "mobile",
initialViewportWidth: 390,
canModify: true,
},
createElement("span", null, "board"),
),
);
});
expect(mocks.layoutOverrideId).toBe(requestedLayoutId);
expect(mocks.initialOpen).toBe(true);
});
test.each([
["without edit mode", "layout=base", true],
["for an unknown layout", "layout=unknown&edit=true", true],
["without modify access", "layout=base&edit=true", false],
])("keeps viewport layout selection %s", (_name, query, canModify) => {
mocks.searchParams = new URLSearchParams(query);
act(() => {
root.render(
createElement(
BoardProviders,
{
initialBoard: { layouts: [{ id: "mobile" }, { id: "base" }] } as never,
initialLayoutId: "mobile",
initialViewportWidth: 390,
canModify,
},
createElement("span", null, "board"),
),
);
});
expect(mocks.layoutOverrideId).toBeNull();
expect(mocks.initialOpen).toBe(false);
});
});
@@ -2,6 +2,7 @@
import type { PropsWithChildren } from "react";
import { useSearchParams } from "next/navigation";
import { useMediaQuery } from "@mantine/hooks";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
@@ -23,10 +24,11 @@ export const BoardProviders = ({
canModify,
}: PropsWithChildren<Props>) => {
const searchParams = useSearchParams();
const isDesktop = useMediaQuery("(min-width: 64em)");
const requestedLayoutId = searchParams.get("layout");
const isRequestedLayoutValid = initialBoard.layouts.some((layout) => layout.id === requestedLayoutId);
const isLayoutEditRequest = canModify && searchParams.get("edit") === "true" && isRequestedLayoutValid;
const layoutOverrideId = isLayoutEditRequest ? requestedLayoutId : null;
const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null;
return (
<BoardProvider
+14 -1
View File
@@ -13,6 +13,7 @@ import { notFound } from "next/navigation";
import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server";
import { env as authEnv } from "@homarr/auth/env";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
@@ -27,6 +28,8 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { Analytics } from "~/components/layout/analytics";
import type { AssistantAvailability } from "~/components/assistant/assistant-gate";
import { AssistantGate } from "~/components/assistant/assistant-gate";
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
import { env } from "~/env";
@@ -97,11 +100,20 @@ export default async function Layout(props: {
})
: null,
);
const [session, user, serverSettings, colorScheme] = await Promise.all([
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
!session
? "unauthenticated"
: api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.catch(() => "error"),
);
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise,
userPromise,
getRscServerSettingsAsync(),
getCurrentColorSchemeAsync(),
assistantAvailabilityPromise,
]);
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
const publicUrls = resolveHomarrUrlConfig({
@@ -145,6 +157,7 @@ export default async function Layout(props: {
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
]);
return (
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
describe("getAssistantConnectionState", () => {
it("marks a saved required-key provider as ready", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: false,
providerRequiresApiKey: true,
apiKeyConfigured: true,
}),
).toEqual({
hasStoredApiKey: true,
connectionPending: false,
connectionReady: true,
});
});
it("requires the endpoint to be saved before exposing model controls", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: true,
providerRequiresApiKey: true,
apiKeyConfigured: true,
}),
).toEqual({
hasStoredApiKey: false,
connectionPending: true,
connectionReady: false,
});
});
it("does not call a required-key connection ready after its key is removed", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: false,
providerRequiresApiKey: true,
apiKeyConfigured: false,
}),
).toEqual({
hasStoredApiKey: false,
connectionPending: true,
connectionReady: false,
});
});
it("allows a saved local endpoint without an API key", () => {
expect(
getAssistantConnectionState({
connectionConfigured: true,
destinationChanged: false,
providerRequiresApiKey: false,
apiKeyConfigured: false,
}),
).toEqual({
hasStoredApiKey: false,
connectionPending: false,
connectionReady: true,
});
});
});
describe("formatAssistantContextWindow", () => {
it("uses Homarr's compact number format for large context windows", () => {
expect(formatAssistantContextWindow(1_048_576)).toBe("1M");
expect(formatAssistantContextWindow(131_072)).toBe("131k");
});
});
@@ -0,0 +1,27 @@
import { formatNumber } from "@homarr/common";
interface AssistantConnectionStateInput {
connectionConfigured: boolean;
destinationChanged: boolean;
providerRequiresApiKey: boolean;
apiKeyConfigured: boolean;
}
export const getAssistantConnectionState = ({
connectionConfigured,
destinationChanged,
providerRequiresApiKey,
apiKeyConfigured,
}: AssistantConnectionStateInput) => {
const hasStoredApiKey = apiKeyConfigured && !destinationChanged;
const credentialsMissing = providerRequiresApiKey && !hasStoredApiKey;
const connectionPending = !connectionConfigured || destinationChanged || credentialsMissing;
return {
hasStoredApiKey,
connectionPending,
connectionReady: !connectionPending,
};
};
export const formatAssistantContextWindow = (tokens: number) => formatNumber(tokens, 0);
@@ -0,0 +1,158 @@
.configuration {
width: 100%;
max-width: 74rem;
}
.summary {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.summaryProvider {
min-width: 0;
}
.summaryProviderText {
min-width: 0;
}
.summaryEndpoint {
max-width: min(46rem, 70vw);
}
.section {
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background-color: var(--mantine-color-body);
}
.sectionHeader {
padding: var(--mantine-spacing-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.sectionHeading {
min-width: 0;
}
.stepNumber {
width: 1.75rem;
height: 1.75rem;
flex: 0 0 1.75rem;
border-radius: 50%;
background-color: var(--mantine-primary-color-light);
color: var(--mantine-primary-color-light-color);
font-size: var(--mantine-font-size-sm);
font-weight: 700;
line-height: 1.75rem;
text-align: center;
}
.sectionBody {
padding: var(--mantine-spacing-md);
}
.sectionActions {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.credentialSummary {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.credentialSummaryContent {
min-width: 0;
}
.advancedControl {
min-height: 3.25rem;
}
.selectedModel {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.selectedModelText {
min-width: 0;
}
.selectedModelBadges {
flex-shrink: 0;
}
.capabilityRow,
.enableRow {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.securityNote {
min-width: 0;
flex: 1;
}
.flowActions {
display: flex;
justify-content: flex-end;
gap: var(--mantine-spacing-xs);
}
@media (max-width: 48em) {
.summaryEndpoint {
max-width: 78vw;
}
.summaryActions,
.credentialActions,
.sectionActions > :global(.mantine-Group-root) {
width: 100%;
}
.summaryActions > *,
.credentialActions > *,
.flowActions > *,
.sectionActions :global(.mantine-Button-root) {
flex: 1 1 auto;
}
.credentialSummary {
align-items: stretch;
}
.flowActions {
display: grid;
grid-template-columns: 1fr;
}
.selectedModel {
align-items: stretch;
flex-direction: column;
}
.selectedModelBadges {
justify-content: flex-start;
flex-shrink: 1;
}
.capabilityRow {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.configuration *,
.configuration *::before,
.configuration *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@@ -0,0 +1,798 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
ActionIcon,
Alert,
Badge,
Box,
Button,
Divider,
Group,
Image,
LoadingOverlay,
Paper,
PasswordInput,
Select,
Skeleton,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconBrandGithub,
IconCheck,
IconCircleCheck,
IconDatabaseSearch,
IconKey,
IconPlus,
IconSearch,
IconShieldCheck,
IconShieldLock,
IconTool,
IconTrash,
IconWorld,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderIds,
assistantProviderPresets,
getAssistantModelOptionLabel,
resolveAssistantModelId,
} from "@homarr/definitions";
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
import classes from "./assistant-configuration.module.css";
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
type HeaderEntry = { id: number; name: string; value: string };
type CredentialFlow = "idle" | "remove";
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider; size?: number }) => {
if (providerId === "homarr") {
return <Image src="/logo/logo.png" alt="" aria-hidden w={size} h={size} fit="contain" />;
}
const preset = assistantProviderPresets[providerId];
if (!preset.iconUrl) return <IconWorld size={size} aria-hidden />;
const darkIconUrl = "darkIconUrl" in preset ? preset.darkIconUrl : null;
if (!darkIconUrl) {
return <Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" />;
}
return (
<>
<Image src={preset.iconUrl} alt="" aria-hidden w={size} h={size} fit="contain" darkHidden />
<Image src={darkIconUrl} alt="" aria-hidden w={size} h={size} fit="contain" lightHidden />
</>
);
};
interface ConfigurationSectionProps {
number: number;
title: string;
description: string;
status?: ReactNode;
actions?: ReactNode;
children: ReactNode;
}
const ConfigurationSection = ({ number, title, description, status, actions, children }: ConfigurationSectionProps) => (
<section className={classes.section}>
<Group className={classes.sectionHeader} justify="space-between" align="flex-start">
<Group className={classes.sectionHeading} gap="sm" align="flex-start" wrap="nowrap">
<Box className={classes.stepNumber} aria-hidden>
{number}
</Box>
<Box>
<Text fw={650}>{title}</Text>
<Text size="sm" c="dimmed" maw="66ch">
{description}
</Text>
</Box>
</Group>
{status}
</Group>
<Stack className={classes.sectionBody} gap="md">
{children}
</Stack>
{actions && <Box className={classes.sectionActions}>{actions}</Box>}
</section>
);
const ConfigurationSkeleton = ({ label }: { label: string }) => (
<Stack className={classes.configuration} gap="md" aria-label={label}>
<Paper withBorder p="md">
<Group justify="space-between">
<Group>
<Skeleton circle h={36} />
<Stack gap={5}>
<Skeleton h={14} w={180} />
<Skeleton h={10} w={280} />
</Stack>
</Group>
<Skeleton h={24} w={110} radius="xl" />
</Group>
</Paper>
{[1, 2, 3].map((number) => (
<Paper key={number} withBorder p="md">
<Stack gap="sm">
<Skeleton h={18} w={220} />
<Skeleton h={12} w="65%" />
<Skeleton h={42} />
</Stack>
</Paper>
))}
</Stack>
);
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>("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");
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
const [clearHeaders, setClearHeaders] = useState(false);
const [nextHeaderId, setNextHeaderId] = useState(1);
const [modelId, setModelId] = useState("");
const [enabled, setEnabled] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
useEffect(() => {
const configuredProvider = configuration?.provider ?? "openrouter";
setProvider(configuredProvider);
setBaseUrl(
configuredProvider === "homarr"
? getWorkshopAssistantProviderUrl()
: (configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl),
);
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
setModelId(configuration?.modelId ?? "");
setEnabled(configuration?.enabled ?? false);
setWebSearchEnabled(configuration?.webSearchEnabled ?? false);
setApiKey("");
setCredentialFlow("idle");
}, [configuration]);
const normalizedBaseUrl = baseUrl.trim().replace(/\/$/, "");
const normalizedModelDiscoveryPath = modelDiscoveryPath.trim() || null;
const destinationChanged =
configuration?.connectionConfigured === true &&
(configuration.provider !== provider || configuration.baseUrl !== normalizedBaseUrl);
const connectionChanged =
configuration?.connectionConfigured === true &&
(destinationChanged || configuration.modelDiscoveryPath !== normalizedModelDiscoveryPath);
const {
data: models,
error: discoveryError,
isFetching: isDiscovering,
refetch: discoverModels,
} = clientApi.assistant.discoverModels.useQuery(undefined, {
enabled:
configuration?.connectionConfigured === true && configuration.modelDiscoveryPath !== null && !connectionChanged,
staleTime: 5 * 60_000,
retry: false,
});
const modelOptions = useMemo(
() =>
(models ?? []).map((model) => ({
value: model.id,
label: getAssistantModelOptionLabel(model),
})),
[models],
);
const providerOptions = useMemo(
() =>
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => {
const items = assistantProviderIds
.filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({
value: providerId,
label: t(`provider.options.${providerId}.label`),
}));
return { group: t(`provider.groups.${category}`), items };
}),
[t],
);
const selectedModel = connectionChanged ? undefined : models?.find((model) => model.id === modelId);
const hasDiscoveredModels = !connectionChanged && (models?.length ?? 0) > 0;
useEffect(() => {
if (!models || models.length === 0) return;
const resolvedModelId = resolveAssistantModelId(models, modelId);
if (resolvedModelId && resolvedModelId !== modelId) {
setModelId(resolvedModelId);
}
}, [modelId, models]);
const preset = assistantProviderPresets[provider];
const isHomarrProvider = provider === "homarr";
const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0);
const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({
connectionConfigured: configuration?.connectionConfigured === true,
destinationChanged,
providerRequiresApiKey: preset.requiresApiKey,
apiKeyConfigured: configuration?.apiKeyConfigured === true,
});
const hasEffectiveApiKey = apiKey.trim().length > 0 || hasStoredApiKey;
const connectionValid =
baseUrl.trim().length > 0 && headerValuesValid && (!preset.requiresApiKey || hasEffectiveApiKey);
const modelControlsDisabled = connectionPending || isDiscovering;
const canSaveConfiguration = !modelControlsDisabled && modelId.trim().length > 0;
const canUseOpenRouterServerTools = assistantProviderCanUseOpenRouterServerTools(provider);
const updateConnection = clientApi.assistant.updateConnection.useMutation({
onSuccess: async ({ credentialsClearedForDestinationChange }) => {
setApiKey("");
setCredentialFlow("idle");
setHeaders([]);
setClearHeaders(false);
await Promise.all([
utils.assistant.getAdminConfiguration.invalidate(),
utils.assistant.discoverModels.invalidate(),
utils.assistant.getAvailability.invalidate(),
]);
showSuccessNotification({
title: t("notification.connectionSaved.title"),
message: credentialsClearedForDestinationChange
? t("notification.connectionSaved.credentialsCleared")
: t("notification.connectionSaved.message"),
});
},
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
});
const saveConfiguration = clientApi.assistant.updateConfiguration.useMutation({
onSuccess: async () => {
await Promise.all([
utils.assistant.getAdminConfiguration.invalidate(),
utils.assistant.getAvailability.invalidate(),
utils.assistant.getRuntimeOptions.invalidate(),
]);
showSuccessNotification({
title: t("notification.saved.title"),
message: t("notification.saved.message"),
});
},
onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }),
});
const pending = updateConnection.isPending || saveConfiguration.isPending;
const resetCredentialFlow = () => {
setApiKey("");
setCredentialFlow("idle");
};
const clearDraftConnectionState = () => {
resetCredentialFlow();
setHeaders([]);
setClearHeaders(false);
setModelId("");
setEnabled(false);
setWebSearchEnabled(false);
};
const changeProvider = (value: string | null) => {
if (!value || !(value in assistantProviderPresets)) return;
const nextProvider = value as AssistantProvider;
const nextPreset = assistantProviderPresets[nextProvider];
clearDraftConnectionState();
setProvider(nextProvider);
setBaseUrl(nextProvider === "homarr" ? getWorkshopAssistantProviderUrl() : nextPreset.baseUrl);
setModelDiscoveryPath(nextPreset.modelDiscoveryPath ?? "");
};
const changeBaseUrl = (value: string) => {
if (value !== baseUrl) {
clearDraftConnectionState();
}
setBaseUrl(value);
};
const changeModelDiscoveryPath = (value: string) => {
if (value !== modelDiscoveryPath) {
setModelId("");
setEnabled(false);
}
setModelDiscoveryPath(value);
};
const addHeader = () => {
setHeaders((current) => [...current, { id: nextHeaderId, name: "", value: "" }]);
setNextHeaderId((current) => current + 1);
setClearHeaders(false);
};
const updateHeader = (id: number, field: "name" | "value", value: string) => {
setHeaders((current) => current.map((header) => (header.id === id ? { ...header, [field]: value } : header)));
};
const saveConnection = (clearApiKey = false) => {
const customHeaders = isHomarrProvider
? undefined
: headers.length > 0
? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value]))
: undefined;
updateConnection.mutate({
provider,
baseUrl,
modelDiscoveryPath: modelDiscoveryPath.trim() || null,
apiKey: clearApiKey || isHomarrProvider ? undefined : apiKey.trim() || undefined,
clearApiKey: clearApiKey || isHomarrProvider,
customHeaders,
clearCustomHeaders: clearHeaders || isHomarrProvider,
});
};
const saveAssistantConfiguration = () => {
if (!canSaveConfiguration) return;
saveConfiguration.mutate({
enabled,
modelId: modelId.trim(),
webSearchEnabled: canUseOpenRouterServerTools && webSearchEnabled,
});
};
if (isLoading) {
return <ConfigurationSkeleton label={t("loading")} />;
}
return (
<>
<Stack className={classes.configuration} gap="md">
<Paper className={classes.summary} withBorder p="md" radius="md" pos="relative">
<LoadingOverlay visible={pending} />
<Group justify="space-between" align="center">
<Group className={classes.summaryProvider} gap="sm" wrap="nowrap">
<ThemeIcon variant="default" size="lg" radius="md">
<ProviderIcon providerId={provider} size={22} />
</ThemeIcon>
<Box className={classes.summaryProviderText}>
<Group gap="xs">
<Text fw={650}>{t(`provider.options.${provider}.label`)}</Text>
<Badge
size="sm"
variant="light"
color={connectionChanged ? "yellow" : connectionReady ? "green" : "gray"}
>
{connectionChanged
? t("overview.unsavedChanges")
: connectionReady
? t("overview.connectionReady")
: t("overview.setupRequired")}
</Badge>
</Group>
<Text className={classes.summaryEndpoint} size="sm" c="dimmed" truncate>
{configuration?.modelId ?? t("overview.noModel")} · {normalizedBaseUrl}
</Text>
</Box>
</Group>
<Group className={classes.summaryActions} gap="xs">
{configuration?.webSearchEnabled && (
<Badge variant="light" leftSection={<IconSearch size={12} />}>
{t("serverTools.webSearch.label")}
</Badge>
)}
{configuration?.enabled === false && (
<Badge variant="filled" color="gray">
{t("overview.disabled")}
</Badge>
)}
</Group>
</Group>
</Paper>
<Group gap="xs" align="flex-start" wrap="nowrap">
<IconShieldCheck size={18} color="var(--mantine-color-blue-6)" aria-hidden />
<Text className={classes.securityNote} size="sm" c="dimmed">
{t("security.description")}
</Text>
</Group>
<ConfigurationSection
number={1}
title={t("connection.endpointTitle")}
description={t("connection.description")}
status={
<Badge variant="light" color={connectionChanged ? "yellow" : "gray"}>
{t(`provider.options.${provider}.label`)}
</Badge>
}
>
<Stack gap="md">
<Select
label={t("provider.title")}
description={t(`provider.options.${provider}.description`)}
value={provider}
onChange={changeProvider}
data={providerOptions}
searchable
allowDeselect={false}
leftSection={<ProviderIcon providerId={provider} />}
renderOption={({ option }) => (
<Group gap="sm" wrap="nowrap" align="center" w="100%">
<ProviderIcon providerId={option.value as AssistantProvider} />
<Box flex={1} miw={0}>
<Group gap="xs" wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{option.label}
</Text>
</Group>
</Box>
</Group>
)}
/>
<TextInput
label={t("baseUrl.title")}
description={t("baseUrl.description")}
leftSection={<IconWorld size={16} />}
value={baseUrl}
disabled={isHomarrProvider}
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
placeholder="https://provider.example/v1"
/>
<TextInput
label={t("model.discoveryPath")}
description={t("model.discoveryPathDescription")}
value={modelDiscoveryPath}
disabled={isHomarrProvider}
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
placeholder="/models"
/>
</Stack>
</ConfigurationSection>
<ConfigurationSection
number={2}
title={t("credentials.title")}
description={t("credentials.description")}
status={
isHomarrProvider ? (
<Badge variant="light" color="blue">
{t("credentials.workshop")}
</Badge>
) : hasStoredApiKey ? undefined : (
<Badge variant="light" color={preset.requiresApiKey ? "yellow" : "gray"}>
{preset.requiresApiKey ? t("credentials.required") : t("credentials.optional")}
</Badge>
)
}
actions={
<Group justify="flex-end">
{credentialFlow === "idle" && (
<Button
leftSection={<IconCheck size={16} />}
onClick={() => saveConnection()}
disabled={!connectionValid}
loading={updateConnection.isPending}
>
{t("connection.save")}
</Button>
)}
</Group>
}
>
{isHomarrProvider ? (
<Alert color="blue" icon={<IconBrandGithub size={18} />} title={t("provider.homarrAuth.title")}>
{t("provider.homarrAuth.description")}
</Alert>
) : hasStoredApiKey && credentialFlow === "idle" ? (
<Group className={classes.credentialSummary} justify="space-between" align="center">
<Group className={classes.credentialSummaryContent} gap="sm" wrap="nowrap">
<ThemeIcon color="teal" variant="light" radius="xl">
<IconShieldLock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{t("apiKey.savedTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("apiKey.savedDescription")}
</Text>
</Box>
</Group>
<Group className={classes.credentialActions} gap="xs">
<Button variant="subtle" color="red" onClick={() => setCredentialFlow("remove")}>
{t("apiKey.remove")}
</Button>
</Group>
</Group>
) : credentialFlow === "remove" ? (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("apiKey.removeFlow.title")}>
<Stack gap="md">
<Text size="sm">
{preset.requiresApiKey
? t("apiKey.removeFlow.description")
: t("apiKey.removeFlow.optionalDescription")}
</Text>
<Box className={classes.flowActions}>
<Button variant="default" onClick={resetCredentialFlow}>
{t("apiKey.removeFlow.cancel")}
</Button>
<Button color="red" loading={updateConnection.isPending} onClick={() => saveConnection(true)}>
{t("apiKey.removeFlow.confirm")}
</Button>
</Box>
</Stack>
</Alert>
) : (
<PasswordInput
label={t("apiKey.title")}
description={preset.requiresApiKey ? t("apiKey.required") : t("apiKey.optional")}
value={apiKey}
onChange={(event) => setApiKey(event.currentTarget.value)}
leftSection={<IconKey size={16} />}
placeholder={t("apiKey.placeholder")}
autoComplete="new-password"
/>
)}
{!isHomarrProvider && (
<Accordion variant="contained" radius="md">
<Accordion.Item value="headers">
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
<Group justify="space-between" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{t("headers.title")}
</Text>
<Text size="xs" c="dimmed">
{t("headers.description")}
</Text>
</Box>
{configuration?.customHeadersConfigured && (
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
</Badge>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
</Text>
<Button
variant="light"
size="compact-sm"
leftSection={<IconPlus size={14} />}
onClick={addHeader}
>
{t("headers.add")}
</Button>
</Group>
{headers.map((header) => (
<Group key={header.id} align="flex-end" wrap="wrap">
<TextInput
flex={1}
miw="12rem"
label={t("headers.name")}
value={header.name}
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
placeholder="X-Provider-Header"
/>
<PasswordInput
flex={2}
miw="12rem"
label={t("headers.value")}
value={header.value}
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
placeholder={t("headers.valuePlaceholder")}
autoComplete="off"
/>
<Tooltip label={t("headers.remove")}>
<ActionIcon
size="lg"
variant="subtle"
color="red"
aria-label={t("headers.remove")}
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
>
<IconTrash size={17} />
</ActionIcon>
</Tooltip>
</Group>
))}
{configuration?.customHeadersConfigured && headers.length === 0 && (
<Button
variant="light"
color={clearHeaders ? "gray" : "red"}
onClick={() => setClearHeaders(!clearHeaders)}
>
{clearHeaders ? t("headers.keep") : t("headers.clear")}
</Button>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</ConfigurationSection>
<ConfigurationSection
number={3}
title={t("model.title")}
description={t("model.description")}
status={
!configuration?.modelId ? (
<Badge variant="light" color="gray">
{t("overview.noModel")}
</Badge>
) : configuration.enabled ? undefined : (
<Badge variant="light" color="gray">
{t("overview.disabled")}
</Badge>
)
}
actions={
<Group justify="flex-end">
<Button
leftSection={<IconCircleCheck size={16} />}
onClick={saveAssistantConfiguration}
disabled={!canSaveConfiguration}
loading={saveConfiguration.isPending}
>
{t("save")}
</Button>
</Group>
}
>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{connectionPending ? t("model.saveConnectionFirst") : t("model.chooseModel")}
</Text>
<Button
variant="light"
size="compact-sm"
leftSection={<IconDatabaseSearch size={14} />}
loading={isDiscovering}
disabled={modelControlsDisabled || configuration?.modelDiscoveryPath === null}
onClick={() => void discoverModels()}
>
{t("model.refresh")}
</Button>
</Group>
{hasDiscoveredModels ? (
<Select
label={t("model.title")}
value={modelId || null}
onChange={(value) => setModelId(value ?? "")}
data={modelControlsDisabled ? [] : modelOptions}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={t("model.discovered", { count: models?.length ?? 0 })}
searchable
allowDeselect={false}
limit={100}
/>
) : (
<TextInput
label={t("model.title")}
value={modelId}
onChange={(event) => setModelId(event.currentTarget.value)}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={
connectionPending
? t("model.saveConnectionFirst")
: isDiscovering
? t("model.discovering")
: discoveryError || models?.length === 0
? t("model.manualFallback")
: t("model.manual")
}
/>
)}
{discoveryError && !connectionPending && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("model.discoveryFailed")}>
{discoveryError.message}
</Alert>
)}
{selectedModel && (
<Group className={classes.selectedModel} justify="space-between" align="flex-start" wrap="nowrap">
<Box className={classes.selectedModelText}>
<Text fw={600}>{selectedModel.name}</Text>
{selectedModel.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{selectedModel.description}
</Text>
)}
</Box>
<Group className={classes.selectedModelBadges} gap="xs" justify="flex-end">
{selectedModel.contextLength && (
<Badge variant="light">
{t("model.context", { count: formatAssistantContextWindow(selectedModel.contextLength) })}
</Badge>
)}
<Badge
variant="light"
color={selectedModel.toolSupport === "confirmed" ? "green" : "gray"}
leftSection={<IconTool size={12} />}
>
{selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")}
</Badge>
</Group>
</Group>
)}
{canUseOpenRouterServerTools && (
<Stack gap="xs">
<Divider label={t("serverTools.title")} labelPosition="left" />
<Group className={classes.capabilityRow} justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap">
<ThemeIcon variant="light" radius="xl" size="md">
<IconSearch size={17} />
</ThemeIcon>
<Box>
<Group gap="xs">
<Text size="sm" fw={600}>
{t("serverTools.webSearch.label")}
</Text>
<Badge size="xs" variant="light">
{t("serverTools.beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed" maw="66ch">
{provider === "custom"
? t("serverTools.webSearch.proxyDescription")
: t("serverTools.webSearch.description")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("serverTools.webSearch.limit")}
</Text>
</Box>
</Group>
<Switch
checked={webSearchEnabled}
onChange={(event) => setWebSearchEnabled(event.currentTarget.checked)}
disabled={modelControlsDisabled}
aria-label={t("serverTools.webSearch.label")}
size="md"
/>
</Group>
</Stack>
)}
<Group className={classes.enableRow} justify="space-between" align="center" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{t("enabled.label")}
</Text>
<Text size="sm" c="dimmed">
{t("enabled.description")}
</Text>
</Box>
<Switch
checked={enabled}
onChange={(event) => setEnabled(event.currentTarget.checked)}
disabled={!canSaveConfiguration}
aria-label={t("enabled.label")}
size="md"
/>
</Group>
</ConfigurationSection>
</Stack>
</>
);
};
@@ -0,0 +1,33 @@
import { notFound } from "next/navigation";
import { Text } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() {
const t = await getScopedI18n("management.page.settings.section.assistant");
return {
title: `${t("title")} • Homarr`,
};
}
export default async function AssistantPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const t = await getScopedI18n("management.page.settings.section.assistant");
return (
<ManagePageLayout title={t("title")}>
<Text c="dimmed" maw="70ch">
{t("description")}
</Text>
<AssistantConfiguration />
</ManagePageLayout>
);
}
@@ -1,12 +1,14 @@
"use client";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCopy } from "@tabler/icons-react";
import { ActionIcon, Group, Tooltip } from "@mantine/core";
import { IconCopy, IconRobot } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonProps {
rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
@@ -21,31 +23,68 @@ export const CopyAiPromptButton = ({
documentationUrl,
}: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const assistant = useOptionalHomarrAssistant();
const prompt = () => buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
const handleAssistant = () => {
if (!assistant?.enabled || !assistant.sendPrompt(prompt())) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
};
const handleCopy = async () => {
try {
const prompt = buildCustomWidgetAiPrompt(undefined, rawResponse, currentConfig, request, documentationUrl);
await navigator.clipboard.writeText(prompt);
const value = prompt();
await navigator.clipboard.writeText(value);
showSuccessNotification({
title: t("action.copyAiPrompt"),
message: t("notification.aiPromptCopiedWithCount", { count: prompt.length }),
message: t("notification.aiPromptCopiedWithCount", { count: value.length }),
});
} catch {
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
}
};
if (!assistant?.enabled) {
return (
<Tooltip label={t("action.copyAiPrompt")}>
<ActionIcon
type="button"
variant="subtle"
size="lg"
aria-label={t("action.copyAiPrompt")}
onClick={() => void handleCopy()}
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
);
}
return (
<Tooltip label={t("action.copyAiPrompt")}>
<ActionIcon
type="button"
variant="subtle"
size="lg"
aria-label={t("action.copyAiPrompt")}
onClick={() => void handleCopy()}
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
<Group gap={2} wrap="nowrap">
<Tooltip label={t("action.aiPrompt")}>
<ActionIcon
type="button"
variant="subtle"
size="lg"
aria-label={t("action.aiPrompt")}
onClick={handleAssistant}
disabled={!request?.trim()}
>
<IconRobot size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("action.copyAiPrompt")}>
<ActionIcon
type="button"
variant="subtle"
size="lg"
aria-label={t("action.copyAiPrompt")}
onClick={() => void handleCopy()}
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
};
@@ -144,7 +144,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
key={id}
component="a"
href={`#${id}`}
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
size="compact-sm"
variant="subtle"
color={invalidSections.has(id) ? "red" : undefined}
@@ -22,7 +22,6 @@ vi.mock("@homarr/api/client", () => ({
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
const rendererAttempt = vi.hoisted(() => vi.fn());
const codeEditorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
default: ({ data }: { data: Record<string, unknown> }) => {
@@ -33,12 +32,7 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
return <div>widget-renderer</div>;
},
}));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { label: string; readOnly?: boolean }) => {
codeEditorProps(props);
return <div>{props.label}</div>;
},
}));
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
let root: Root;
@@ -115,7 +109,6 @@ describe("Custom Widget preview panel", () => {
);
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true }));
expect(host.textContent).not.toContain("invalid");
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
@@ -172,7 +172,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
label={t("requestData")}
language="json"
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
readOnly
onChange={() => undefined}
/>
</Tabs.Panel>
@@ -252,7 +251,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
label={t("journal")}
language="json"
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
readOnly
onChange={() => undefined}
/>
)}
@@ -87,9 +87,7 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
};
const addSource = () => {
let suffix = sources.length + 1;
while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1;
const id = sources.length === 0 ? "default" : `source-${suffix}`;
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
form.setFieldValue(
"sources",
JSON.stringify(
Loaded 100 of 512 files, more files were not shown because too many files have changed in this diff. Show more