Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3ae23660 | ||
|
|
8d4bf04452 | ||
|
|
65b725e1bb | ||
|
|
5e631b2f80 | ||
|
|
0b7b0b9650 | ||
|
|
0c59c7572f | ||
|
|
6dc1ee4536 | ||
|
|
f9e62a4932 | ||
|
|
315245e048 | ||
|
|
ebdfeb724b | ||
|
|
e842177d9b | ||
|
|
e3a713d2df | ||
|
|
e7879ed3a2 | ||
|
|
19e72fc757 | ||
|
|
38fc5dd582 | ||
|
|
6d284adb98 | ||
|
|
bd171bf73b | ||
|
|
1ee519bb7a | ||
|
|
c53852fdb6 | ||
|
|
206905ea2d | ||
|
|
50537d077c | ||
|
|
229a559aff | ||
|
|
4e514ab151 | ||
|
|
960551f573 | ||
|
|
304ea089a2 | ||
|
|
199f6ffc3b | ||
|
|
7e569bff4c | ||
|
|
0ec5f36050 | ||
|
|
e22be4dca6 | ||
|
|
3237d5953f | ||
|
|
1325418300 | ||
|
|
4505ce96c9 | ||
|
|
b491d45fdc | ||
|
|
168091134d | ||
|
|
28e58a5a5c | ||
|
|
3c533f2d96 | ||
|
|
2e3e654748 | ||
|
|
409e8ed71a | ||
|
|
95489b692c | ||
|
|
b8c06a8622 | ||
|
|
f62add8ae4 | ||
|
|
498d781e19 | ||
|
|
0afed2c3f6 | ||
|
|
efc0ee470b | ||
|
|
fe41382074 | ||
|
|
c851609041 | ||
|
|
03338b1755 | ||
|
|
8a44451c72 | ||
|
|
d434953478 | ||
|
|
7aaa646b82 | ||
|
|
c4f6d98300 | ||
|
|
81fdc5856b | ||
|
|
4e193da3a4 | ||
|
|
29f7c35fc1 | ||
|
|
8775597d5a | ||
|
|
339a0d8e2b | ||
|
|
4ddc7266e9 | ||
|
|
42bf55641b | ||
|
|
c92e074a6f | ||
|
|
741e641bd2 | ||
|
|
9b7f189b02 | ||
|
|
0070538857 | ||
|
|
a11c53ebf7 | ||
|
|
e55def9b60 | ||
|
|
fc66211dd2 | ||
|
|
174e0a7606 | ||
|
|
30cb066b7f | ||
|
|
f1ce0fffad | ||
|
|
9c5ab62b3a | ||
|
|
6adb49eedf | ||
|
|
6482ea6ae8 | ||
|
|
f7d5fde8a9 | ||
|
|
de368a6969 | ||
|
|
e1280f311c | ||
|
|
517dcfa64e | ||
|
|
6faba5f772 | ||
|
|
cca6de2b86 | ||
|
|
c24ccde404 | ||
|
|
d28dd380e5 | ||
|
|
1de167c98d | ||
|
|
7b1a801ab8 | ||
|
|
8239a631f2 | ||
|
|
775ae0147c | ||
|
|
ece6757979 | ||
|
|
dd707bed7e | ||
|
|
276431ef4c | ||
|
|
c25c1dbac7 | ||
|
|
66ad01e7ce | ||
|
|
d4ffd19f01 | ||
|
|
4f693b0465 | ||
|
|
6437e57565 | ||
|
|
2566be2838 | ||
|
|
dc71084323 | ||
|
|
e41605640b | ||
|
|
d091362db2 | ||
|
|
653dadaeff | ||
|
|
af6fb75e6b | ||
|
|
b2fbd10965 | ||
|
|
63dca7d1b6 | ||
|
|
22d5770977 | ||
|
|
dcef36ffa7 | ||
|
|
f65969053a | ||
|
|
8eb6735387 | ||
|
|
7841359917 | ||
|
|
a45b077b7c | ||
|
|
111a72c283 | ||
|
|
b988e431e8 | ||
|
|
87f264a286 | ||
|
|
83f80eb9a7 | ||
|
|
3c6b2a5d49 | ||
|
|
1b658b631c | ||
|
|
384dc46f26 | ||
|
|
48abdb52af | ||
|
|
45d8bc9443 | ||
|
|
b8f360c04e | ||
|
|
fc5ba7d888 | ||
|
|
b7fdff9f7f | ||
|
|
dfafad15bf | ||
|
|
ceed0979a1 | ||
|
|
474d9968b5 | ||
|
|
295eb836bb | ||
|
|
7b11d6673b | ||
|
|
ce48cabb36 | ||
|
|
443fbe0c36 | ||
|
|
9c64f8772d | ||
|
|
c0596d1b5e | ||
|
|
517d9320dc | ||
|
|
bb6e8e5c5a | ||
|
|
f4f6773f26 | ||
|
|
aea2832370 | ||
|
|
59cb3a4d99 | ||
|
|
ebd63bea74 | ||
|
|
ef9c37d5ec | ||
|
|
a1c2559541 | ||
|
|
d80e883479 | ||
|
|
3cef3cbdbd | ||
|
|
ae68d599be | ||
|
|
e4bf2aa13d | ||
|
|
4ed1eb5d59 | ||
|
|
66043eb78d | ||
|
|
8a1b2a5cd6 | ||
|
|
0298256b62 | ||
|
|
a9b0c2ae47 | ||
|
|
f6f755d5c9 | ||
|
|
b3e75df7fa | ||
|
|
bcd3d901d2 | ||
|
|
30ca26cb1a | ||
|
|
bb5a9562d5 | ||
|
|
5501eb555e | ||
|
|
28a65d61f9 | ||
|
|
f98766873f | ||
|
|
7bf22639ba | ||
|
|
f991dc6334 | ||
|
|
1ad1439828 | ||
|
|
9918386257 | ||
|
|
97a399d0fa | ||
|
|
f357eb4da6 | ||
|
|
d6a259bdbe | ||
|
|
9588c3860d | ||
|
|
c0cdedc039 | ||
|
|
1f60b4b4be | ||
|
|
8d06201b41 | ||
|
|
edf3f6cd84 | ||
|
|
9f2b52dd7f | ||
|
|
87a42f09be | ||
|
|
d40e6852e6 | ||
|
|
37f5473054 | ||
|
|
3d7c95ddcc | ||
|
|
454a35d1a9 | ||
|
|
a979e8d78a | ||
|
|
5ceef06b4b | ||
|
|
33695753dd | ||
|
|
3faac7a96d | ||
|
|
38440a2e57 | ||
|
|
c5800785c9 | ||
|
|
b66c73141f | ||
|
|
0a88db1296 | ||
|
|
23e4fae275 | ||
|
|
1e058c6ed1 | ||
|
|
ebf6c0d97b | ||
|
|
5ede7ef4b9 | ||
|
|
165d148cfc | ||
|
|
8e0a52fcd0 | ||
|
|
f3420a4149 | ||
|
|
6dc9a565b0 | ||
|
|
07547a462c | ||
|
|
73e4b80a26 | ||
|
|
4b647ec2c9 | ||
|
|
03c448f1f8 | ||
|
|
e77174ff15 | ||
|
|
18ed16d07e | ||
|
|
9c57e0cff9 | ||
|
|
c45a9d047d | ||
|
|
efe477e5b5 | ||
|
|
41389b61e2 | ||
|
|
0c5fc1d331 | ||
|
|
6eb75ca559 | ||
|
|
1656890b77 | ||
|
|
3ed839334b | ||
|
|
b5162a9b95 | ||
|
|
40c7b1523b | ||
|
|
d37a2ba963 | ||
|
|
26a9752206 | ||
|
|
3e1257bafc | ||
|
|
63699b1610 | ||
|
|
27448868dd | ||
|
|
8a8e222da3 | ||
|
|
f3360e0d97 | ||
|
|
8d9d2fe802 | ||
|
|
96d2c78879 | ||
|
|
e9e3a523dc | ||
|
|
0ef0e32e18 | ||
|
|
cba2df72e2 | ||
|
|
c0445cdca2 | ||
|
|
5f3d676af0 | ||
|
|
e9b24f1242 | ||
|
|
5db95f2def | ||
|
|
10ef871777 | ||
|
|
854b59ede4 | ||
|
|
eb918b805a | ||
|
|
4a4b04d471 | ||
|
|
3943741838 | ||
|
|
e425e060d9 | ||
|
|
6754e53f56 | ||
|
|
357adffb30 | ||
|
|
d24eca12ef | ||
|
|
dfcd1dbb64 | ||
|
|
252ce998ae | ||
|
|
a940a8223d | ||
|
|
bbd1abd4f0 | ||
|
|
7adc18b1ce | ||
|
|
6a5162d615 | ||
|
|
02fe99d5aa | ||
|
|
7bbca3bd40 | ||
|
|
923a528038 | ||
|
|
bb599e7fae | ||
|
|
bb5df3a2e6 | ||
|
|
c805d5c631 | ||
|
|
747234c040 | ||
|
|
b828f59568 | ||
|
|
60ea03e72e | ||
|
|
e9853f1fb1 | ||
|
|
f8b364dce2 | ||
|
|
cd8773681f | ||
|
|
2f99f1f34b | ||
|
|
6fc851f054 | ||
|
|
7bedf4a13d | ||
|
|
a9c8b65c14 | ||
|
|
e336059079 | ||
|
|
708105ab3f | ||
|
|
e6bd1adfc2 | ||
|
|
b217d8e026 | ||
|
|
35b7c4fc2d | ||
|
|
251a4c8ae8 | ||
|
|
b4404113c4 | ||
|
|
c1efaabd36 | ||
|
|
b75483982f | ||
|
|
c1da19c9b2 | ||
|
|
46898cd0ea | ||
|
|
6f4cd7522f | ||
|
|
eaaeaaccdb | ||
|
|
d63653142d | ||
|
|
08588da0d1 | ||
|
|
464f30799a | ||
|
|
bd4bbcaca2 | ||
|
|
09b82ba171 | ||
|
|
f38eb034da | ||
|
|
a3a89db727 | ||
|
|
c099a8eef6 | ||
|
|
9437d211c8 | ||
|
|
096cf56ac9 | ||
|
|
851129e62e | ||
|
|
cc1b22f052 | ||
|
|
5df4358f0d | ||
|
|
821b5a555d | ||
|
|
a0f4a58386 | ||
|
|
7c840bcfa0 | ||
|
|
38d30acb87 | ||
|
|
ae515584cc | ||
|
|
9338d6d4ec | ||
|
|
d98b7797c1 | ||
|
|
3c739dc636 | ||
|
|
639cb8d35f | ||
|
|
f1ac482684 | ||
|
|
9cc7fd2051 | ||
|
|
d4d951b99c | ||
|
|
a4f115064e | ||
|
|
07e0642192 | ||
|
|
965ccb32d6 | ||
|
|
be08f70c8d | ||
|
|
4892c399ba | ||
|
|
687fb323f7 | ||
|
|
56cc021f98 | ||
|
|
836a4cd46b | ||
|
|
a607dff50a | ||
|
|
68751d85dc | ||
|
|
869dd45de5 | ||
|
|
352aa7dd64 | ||
|
|
801221d2da | ||
|
|
861538e9a0 | ||
|
|
cb7b49b3e7 | ||
|
|
f5cced4080 | ||
|
|
e429d7540a | ||
|
|
3bd80dcc58 | ||
|
|
0e01ac67df | ||
|
|
8259df58f6 | ||
|
|
3485ef0877 | ||
|
|
2214acd17e | ||
|
|
25b835dca9 | ||
|
|
2efa6bcd4c | ||
|
|
c67c893f8f | ||
|
|
e7e44375ef | ||
|
|
842e86012d | ||
|
|
a8a7545f40 | ||
|
|
ed7b30dc5f | ||
|
|
c09ed96978 | ||
|
|
44b55a401c | ||
|
|
f00f5b83b5 | ||
|
|
5553c4e0c5 | ||
|
|
6cdda54e10 | ||
|
|
0f9d038aaf | ||
|
|
a38e731209 | ||
|
|
b1b17ad69f | ||
|
|
add40370aa | ||
|
|
a2763d1f6f | ||
|
|
f3048f4804 | ||
|
|
6bfa9c33d0 | ||
|
|
16982aa178 | ||
|
|
a2400f8412 | ||
|
|
6e455760a3 | ||
|
|
0b9b77c632 | ||
|
|
346bff540f | ||
|
|
f8771c371f | ||
|
|
71cc3abbe9 | ||
|
|
a098c42013 | ||
|
|
4cf83443fd | ||
|
|
f5b4889663 | ||
|
|
06aa5caf0b | ||
|
|
d3534dfcf8 | ||
|
|
f7591dd196 | ||
|
|
0c5f7a45bb | ||
|
|
cdbba92f31 | ||
|
|
3b0633f6a8 | ||
|
|
d0880aa04c | ||
|
|
7cf6a5d5ab | ||
|
|
d5763737c3 | ||
|
|
00b0b215b0 | ||
|
|
5f08327c5c | ||
|
|
2645cb426a | ||
|
|
65e866064e | ||
|
|
392df12d8a | ||
|
|
6209980b27 | ||
|
|
3f04cf83a2 | ||
|
|
e5a8194c03 | ||
|
|
90cf0e1127 | ||
|
|
498481d539 | ||
|
|
65da74858b | ||
|
|
af2b7b3157 | ||
|
|
db40779492 | ||
|
|
91aeac8377 | ||
|
|
dfe2ddf34b | ||
|
|
34f2a0bd24 | ||
|
|
4227e392b5 | ||
|
|
6893205ee0 | ||
|
|
28ac23e66b | ||
|
|
cddae6719e | ||
|
|
0cb75982fc | ||
|
|
36de8131c8 | ||
|
|
2f28f45ff1 | ||
|
|
915af9ae96 | ||
|
|
db5d088330 | ||
|
|
d1a2ed8fd7 | ||
|
|
d6b114e423 | ||
|
|
e41d938a5f | ||
|
|
afa54755fd |
No files matched your search
@@ -1,8 +0,0 @@
|
||||
version = 1
|
||||
|
||||
[[analyzers]]
|
||||
name = "javascript"
|
||||
|
||||
[analyzers.meta]
|
||||
plugins = ["react"]
|
||||
environment = ["nodejs"]
|
||||
@@ -67,7 +67,8 @@ HOMARR_WEBSITE_URL=https://homarr.dev
|
||||
# PocketBase API used by Homarr and by the standalone documentation site.
|
||||
# Defaults to HOMARR_WEBSITE_URL.
|
||||
# WORKSHOP_API_URL=https://homarr.dev
|
||||
# Public Workshop page opened from Homarr. Defaults to HOMARR_WEBSITE_URL/workshop.
|
||||
# Public Workshop website used by links in Homarr, Custom Widgets, onboarding,
|
||||
# and assistant context. Defaults to HOMARR_WEBSITE_URL/workshop.
|
||||
# WORKSHOP_WEB_URL=https://homarr.dev/workshop
|
||||
|
||||
# Documentation and PocketBase deployment settings.
|
||||
|
||||
@@ -3,12 +3,12 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches: ["*"]
|
||||
branches: ["**"]
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
branches: ["*"]
|
||||
branches: ["**"]
|
||||
push:
|
||||
branches: [main, dev, beta, next, v2]
|
||||
branches: [main, dev, beta, next, v2, release/v2]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -95,12 +95,6 @@ jobs:
|
||||
- name: Validate checked-in OpenAPI schema
|
||||
run: pnpm check:openapi
|
||||
|
||||
- name: Validate feature contracts
|
||||
run: pnpm check:feature-contracts
|
||||
|
||||
- name: Test feature platform
|
||||
run: pnpm test:feature-platform
|
||||
|
||||
- name: Validate Custom Widget bundle budget
|
||||
run: |
|
||||
pnpm --filter nextjs build
|
||||
@@ -111,18 +105,20 @@ jobs:
|
||||
|
||||
container:
|
||||
name: Container and E2E
|
||||
needs: fast-gate
|
||||
needs: [fast-gate, preview-amd64]
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
!cancelled() && needs.fast-gate.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.preview-build.outputs.digest }}
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
@@ -137,8 +133,18 @@ jobs:
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Reuse amd64 preview image
|
||||
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
|
||||
env:
|
||||
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
|
||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
|
||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
|
||||
|
||||
- name: Build container once
|
||||
if: "!env.ACT"
|
||||
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -153,8 +159,8 @@ jobs:
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
||||
cache-from: type=gha,scope=homarr-linux-amd64
|
||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||
cache-from: type=gha,scope=homarr-e2e
|
||||
cache-to: type=gha,scope=homarr-e2e,mode=max
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
@@ -195,7 +201,10 @@ jobs:
|
||||
run: pnpm exec playwright install-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm test:e2e
|
||||
env:
|
||||
NODE_ENV: development
|
||||
CI: "true"
|
||||
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
|
||||
|
||||
- name: Upload E2E artifacts
|
||||
if: failure() && !env.ACT
|
||||
@@ -205,27 +214,44 @@ jobs:
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Log in to GHCR for preview publication
|
||||
if: |
|
||||
!env.ACT &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
preview-amd64:
|
||||
name: Build amd64 preview image
|
||||
needs: fast-gate
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.preview-build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: "!env.ACT"
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push amd64 preview image by digest
|
||||
- name: Set up Docker Buildx
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push amd64 preview image by digest
|
||||
id: preview-build
|
||||
if: |
|
||||
!env.ACT &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
if: "!env.ACT"
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -233,22 +259,20 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
outputs: type=image,"name=${{ env.PREVIEW_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=homarr-linux-amd64
|
||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Show Act publication result
|
||||
if: env.ACT
|
||||
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
|
||||
|
||||
preview-arm64:
|
||||
name: Build arm64 preview image
|
||||
needs: fast-gate
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -296,10 +320,11 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [container, preview-arm64]
|
||||
needs: [preview-amd64, preview-arm64]
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -309,7 +334,7 @@ jobs:
|
||||
packages: write
|
||||
pull-requests: read
|
||||
env:
|
||||
PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
PREVIEW_TAG: ${{ github.event_name == 'push' && 'v2' || format('pr-{0}', github.event.pull_request.number || inputs.pr_number) }}
|
||||
steps:
|
||||
- name: Log in to GHCR
|
||||
if: "!env.ACT"
|
||||
@@ -346,7 +371,7 @@ jobs:
|
||||
- name: Publish preview manifest
|
||||
if: "!env.ACT"
|
||||
env:
|
||||
AMD64_DIGEST: ${{ needs.container.outputs.digest }}
|
||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
permissions:
|
||||
statuses: write
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -16,12 +17,20 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
run: |
|
||||
gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
|
||||
-f 'state=success' \
|
||||
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
|
||||
-f 'description=Skipped for non renovate branches' \
|
||||
-f 'context=renovate/stability-days'
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
|
||||
-f 'state=success' \
|
||||
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
|
||||
-f 'description=Skipped for non renovate branches' \
|
||||
-f 'context=renovate/stability-days' \
|
||||
--silent; then
|
||||
exit 0
|
||||
fi
|
||||
echo "status POST failed (attempt $attempt/5), retrying in 5s"
|
||||
sleep 5
|
||||
done
|
||||
exit 1
|
||||
@@ -3,7 +3,7 @@ name: Workshop
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [dev, v2, main]
|
||||
branches: [dev, v2, release/v2, main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'main')
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
images: ghcr.io/homarr-labs/workshop
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=v2,enable=${{ github.ref_name == 'v2' }}
|
||||
type=raw,value=v2,enable=${{ github.ref_name == 'v2' || github.ref_name == 'release/v2' }}
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
target: Workshop store in the docs
|
||||
total_score: 20
|
||||
p0_count: 1
|
||||
p1_count: 3
|
||||
timestamp: 2026-07-21T11-32-50Z
|
||||
slug: apps-docs-src-components-workshop
|
||||
---
|
||||
|
||||
# Workshop Store Critique
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key issue |
|
||||
| --------- | ----------------------------------- | --------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Visibility of system status | 1/4 | Fetch failure can show beside an empty-store message, so the system communicates two incompatible states. |
|
||||
| 2 | Match between system and real world | 3/4 | Widget, CSS, voting, revisions, and comments use familiar language. |
|
||||
| 3 | User control and freedom | 2/4 | Filters are reversible, but the crashed route and failed fetch state offer no useful recovery. |
|
||||
| 4 | Consistency and standards | 2/4 | The submission editor uses CodeMirror, while revision editing falls back to a textarea; several tiny custom controls do not follow the shared button vocabulary. |
|
||||
| 5 | Error prevention | 2/4 | Submission validation exists, but disabled progression does not explain what is missing and destructive actions use a brief inline confirmation. |
|
||||
| 6 | Recognition rather than recall | 3/4 | Labels, type choices, and the submission stepper are understandable without documentation. |
|
||||
| 7 | Flexibility and efficiency | 2/4 | Search covers titles only, signed-out users can select Yours, and installation is reduced to ambiguous Copy and Download actions. |
|
||||
| 8 | Aesthetic and minimalist design | 3/4 | The restrained visual system is clean, but the browse toolbar and card action rows become dense. |
|
||||
| 9 | Help users recover from errors | 0/4 | The production route crashes with React error 130; the fetch error has no retry action or useful context. |
|
||||
| 10 | Help and documentation | 2/4 | The editor explains its fields, but the store does not teach the full inspect, trust, download, and install journey. |
|
||||
| **Total** | | **20/40** | **Needs work before release** |
|
||||
|
||||
## Anti-Patterns Verdict
|
||||
|
||||
The surface does not look like obvious AI-generated decoration. Its visual restraint, familiar controls, and practical editor are credible product UI. The weakness is product behavior rather than styling: contradictory states, tiny action controls, an overloaded responsive toolbar, and a primary installation journey that is not explicit.
|
||||
|
||||
The deterministic Impeccable scan returned zero findings for `apps/docs/src/components/workshop`. This is useful but incomplete. The detector did not catch runtime failures, semantic state conflicts, touch-target sizing, or responsive grouping. Browser evidence found a full route crash, React error 130, and an earlier state that rendered both `Something went wrong.` and `No submissions yet`.
|
||||
|
||||
No visual overlay was injected because the available browser evaluation surface is read-only. Screenshots, DOM snapshots, console logs, and source inspection were used instead.
|
||||
|
||||
## Overall Impression
|
||||
|
||||
The submission modal is now one of the stronger parts of the Workshop. The store around it is not release-ready. The single biggest opportunity is to make discovery and installation a dependable, explicit path: load reliably, show one truthful state, help users evaluate a submission, and give them one obvious next action.
|
||||
|
||||
## What Is Working
|
||||
|
||||
1. The visual system is restrained and consistent with the Homarr documentation. Typography, color, radii, and spacing do not compete with the content.
|
||||
2. The submission flow chunks a complex task into Type, Details, and Media. The shared JSON/CSS editor, drag-and-drop import, metadata autofill, and capped media previews reduce creator effort.
|
||||
3. The detail design exposes revision, author, reports, changelog, source content, and comments. This supports inspection rather than treating community code as a blind install.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
### P0: The Workshop route crashes in the production docs build
|
||||
|
||||
**Why it matters:** No visual polish matters if the store becomes a Docusaurus error page. A fresh independent tab consistently reached `This page crashed` with React error 130. The console attributed the thrown error to the Kapa widget bundle.
|
||||
|
||||
**Fix:** Isolate or disable the Kapa script on Workshop routes, prevent third-party widget failures from reaching the Docusaurus route boundary, and add a route-level Workshop boundary that preserves navigation and retry. Reproduce against the built Docker docs image, not only the development server.
|
||||
|
||||
**Suggested command:** `$impeccable harden`
|
||||
|
||||
### P1: Failure and empty states contradict each other
|
||||
|
||||
**Why it matters:** Showing `Something went wrong.` followed by `No submissions yet` makes users believe the store is both broken and empty. The generic message gives no cause and no recovery.
|
||||
|
||||
**Fix:** Make loading, failure, empty, and results mutually exclusive. The failure state should say that listings could not be loaded, preserve the current filters, and provide `Try loading again`. Do not render filters or an empty store as authoritative data after the initial request fails.
|
||||
|
||||
**Suggested command:** `$impeccable harden`
|
||||
|
||||
### P1: Mobile discovery controls are too dense and do not form a responsive hierarchy
|
||||
|
||||
**Why it matters:** The toolbar's inner row contains four type choices, sorting, and outdated visibility without wrapping. Most controls are 24 to 28 pixels tall. At narrow widths this is difficult to scan, easy to clip, and below a comfortable touch target.
|
||||
|
||||
**Fix:** Put a full-width search first on mobile, keep All, Widgets, and CSS as the primary segmented choice, move sorting and outdated visibility into a compact secondary row or popover, and hide Yours when signed out or make it a sign-in affordance. Use at least 40 to 44 pixel touch targets. Add `aria-pressed` or proper tab semantics to the type filter.
|
||||
|
||||
**Suggested command:** `$impeccable adapt`
|
||||
|
||||
### P1: The store does not make the install journey obvious
|
||||
|
||||
**Why it matters:** A first-time user sees Copy and Download without knowing which is recommended, what will be copied, how to inspect safety, or where the file goes in Homarr. This weakens the store's main purpose.
|
||||
|
||||
**Fix:** Make the detail page the decision surface. Use one primary action named for its result, such as `Download widget JSON` or `Copy CSS`, with a short `Install in Homarr` explanation linked to the correct in-app flow. Cards should prioritize title, preview, compatibility, score, and `View details`; secondary utilities should not compete with evaluation.
|
||||
|
||||
**Suggested command:** `$impeccable clarify`
|
||||
|
||||
### P2: Card and moderation actions are too small and too numerous
|
||||
|
||||
**Why it matters:** Vote, copy, download, comment, delete, and report controls compete in a compact footer. Low-contrast icon buttons are hard to discover and hard to use by touch. CSS columns also create a top-to-bottom visual reading order that can differ from the expected row order.
|
||||
|
||||
**Fix:** Use a regular responsive grid, remove hover scaling, keep one or two visible card actions, and move owner/moderation actions to a menu. Keep voting visible but increase its target size. Correct the report copy: reports and reporter identities are moderation data, not public social proof.
|
||||
|
||||
**Suggested command:** `$impeccable distill`
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
### Jordan, first-time self-hoster
|
||||
|
||||
Jordan opens Workshop to install a widget. The route can crash before any listing appears. In the partial failure state, Jordan sees both an error and an empty store. If listings load, Copy and Download do not explain the Homarr destination or recommended path. Jordan is likely to leave before installing anything.
|
||||
|
||||
### Alex, experienced Homarr administrator
|
||||
|
||||
Alex can understand the JSON and appreciates the source viewer, but title-only search and limited filtering make a larger catalog inefficient. Compatibility and required secret information are not prominent at browse time. Tiny moderation and owner controls slow repeated management tasks.
|
||||
|
||||
### Morgan, mobile user
|
||||
|
||||
Morgan encounters a toolbar with more controls than fit comfortably in one decision group, 24 to 28 pixel targets, and a signed-out Yours filter that leads to an unhelpful no-results state. Submission cards add another dense row of small actions.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- Future submission steps are focusable buttons even when clicking them does nothing. Disable them or render non-interactive progress markers.
|
||||
- The report textarea relies on placeholder text and needs a persistent accessible label.
|
||||
- The revision editor should reuse the same JSON/CSS CodeMirror component as creation.
|
||||
- Search should include title, description, and author at minimum.
|
||||
- The timed inline `Confirm?` deletion pattern is easy to miss and weak for keyboard and touch users.
|
||||
- The detail-page changelog uses an uppercase tracked label, which is unnecessary in this otherwise restrained interface.
|
||||
- Loading comments should use the same skeleton vocabulary as listings rather than centered status text.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. Is Workshop primarily a catalog for discovery, or a technical exchange for copying source? The primary card and detail actions should commit to one answer.
|
||||
2. Should users evaluate compatibility and required secrets before opening a listing, or only on the detail page?
|
||||
3. Does Workshop need every moderation and ownership action visible inline, or should those actions live behind a menu?
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
target: Workshop store in the docs
|
||||
total_score: 38
|
||||
p0_count: 0
|
||||
p1_count: 0
|
||||
timestamp: 2026-07-21T12-29-41Z
|
||||
slug: apps-docs-src-components-workshop
|
||||
---
|
||||
|
||||
# Workshop Store Critique — Post-Polish
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Evidence |
|
||||
| --------- | ----------------------------------- | --------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Visibility of system status | 4/4 | Loading, initial failure, stale refresh failure, empty, and results states are mutually exclusive; recovery actions are visible. |
|
||||
| 2 | Match between system and real world | 4/4 | Actions describe their result: Download widget JSON, Copy CSS, View details, and Install in Homarr. |
|
||||
| 3 | User control and freedom | 4/4 | Filters can be cleared, errors retried, dialogs cancelled, and destructive submission deletion requires explicit confirmation. |
|
||||
| 4 | Consistency and standards | 4/4 | Creation and revision editing share CodeMirror, buttons follow the shared system, and cards use a predictable responsive grid. |
|
||||
| 5 | Error prevention | 3/4 | Validation, disabled progression, pending states, RLS, and confirmations are strong; disabled form progression could explain unmet requirements more explicitly. |
|
||||
| 6 | Recognition rather than recall | 4/4 | Persistent labels, type descriptions, step status, source previews, and installation instructions minimize recall. |
|
||||
| 7 | Flexibility and efficiency | 4/4 | Search spans title, description, and author; signed-in filtering, sorting, responsive controls, and direct detail actions support repeat use. |
|
||||
| 8 | Aesthetic and minimalist design | 3/4 | The catalog is restrained and scannable; long source documents and the complete creator form necessarily remain dense on small screens. |
|
||||
| 9 | Help users recover from errors | 4/4 | Route, listing, edit, report, copy, OAuth, and detail failures remain local and provide a clear next action. |
|
||||
| 10 | Help and documentation | 4/4 | The detail page explains the exact Homarr destination and links to updated Workshop documentation. |
|
||||
| **Total** | | **38/40** | **Release-quality; minor polish remains** |
|
||||
|
||||
## Anti-Patterns Verdict
|
||||
|
||||
The surface is product-led rather than decorative. The redesign removes the overloaded card footer, hover scaling, tiny touch controls, public moderation details, contradictory states, and ambiguous install actions. The deterministic Impeccable detector reports zero findings.
|
||||
|
||||
## What Improved
|
||||
|
||||
1. The production route no longer collapses into the Docusaurus crash screen without recovery. A route-local boundary protects navigation, and the fresh-volume production path renders without console errors.
|
||||
2. Loading, failure, empty, stale-results, and populated states are truthful and mutually exclusive.
|
||||
3. Search and filters form a clear responsive hierarchy. Mobile targets are 40–44 pixels, signed-out users do not see Yours, and cards no longer overflow at 390 pixels.
|
||||
4. Cards prioritize evaluation: title, author, type, score, description, comments, and View details. Copy, download, reporting, editing, and deletion moved to the detail page.
|
||||
5. The detail page now teaches the installation journey and names its primary action precisely.
|
||||
6. Reports and reporter identities are admin-only through PocketBase rules. Public catalog UI no longer turns reports into social proof.
|
||||
7. The creator and revision dialogs share the CodeMirror editor, use bounded internal scrolling, expose pending/error states, and avoid step-selection layout shifts.
|
||||
|
||||
## Remaining Minor Opportunities
|
||||
|
||||
### P2: Explain disabled progression inline
|
||||
|
||||
The Type and Details steps are understandable, but a disabled Next button could state the missing requirement for keyboard and first-time users. This is a refinement, not a blocker, because required fields and validation messages are already present.
|
||||
|
||||
### P2: Consider a compact source summary on very small screens
|
||||
|
||||
The read-only source viewer is intentionally complete and horizontally scrollable. A future collapse/expand summary could reduce page length for nontechnical visitors without hiding inspection entirely.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- Fresh Docker Compose PocketBase volume and production docs image.
|
||||
- Populated catalog with widget, CSS, and outdated examples.
|
||||
- Desktop and 390px mobile layouts in light and dark themes.
|
||||
- Widget detail, installation instructions, CodeMirror source view, and comments.
|
||||
- Signed-in submission wizard through Type and Details, including the shared editor.
|
||||
- Zero browser console errors after the final fixes.
|
||||
- Impeccable deterministic detector: zero findings.
|
||||
|
||||
## Compatibility Note
|
||||
|
||||
An existing local development volume created from an earlier unreleased version of the reset migration still has a stale Workshop view. It is intentionally not migrated or deleted. A fresh volume created from the current unreleased migration is the supported validation target and works correctly.
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
target: Homarr Workshop docs UI
|
||||
total_score: 27
|
||||
p0_count: 0
|
||||
p1_count: 2
|
||||
timestamp: 2026-07-22T15-58-15Z
|
||||
slug: apps-docs-src-components-workshop
|
||||
---
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
| --------- | ------------------------------- | --------: | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Visibility of System Status | 3 | Strong loading, retry, stale-data, pending, and copy states; successful report/edit actions still end silently. |
|
||||
| 2 | Match System / Real World | 3 | Core Workshop language is clear, but `networkScope`, query/action, and schema terminology assumes technical fluency. |
|
||||
| 3 | User Control and Freedom | 3 | Back, cancel, clear filters, vote toggling, and destructive confirmations are present; destructive actions have no undo. |
|
||||
| 4 | Consistency and Standards | 3 | Shared primitives are coherent, though Docusaurus chrome and the denser app-store vocabulary still feel slightly stitched together. |
|
||||
| 5 | Error Prevention | 2 | Validation and confirmations are good, but install safety and credential context appears after download/copy actions on small screens. |
|
||||
| 6 | Recognition Rather Than Recall | 3 | Author, revision, source, filters, and capabilities are visible; installation still asks users to remember a separate Homarr navigation path. |
|
||||
| 7 | Flexibility and Efficiency | 2 | Search, filters, sort, drag-and-drop, autofill, and comment shortcuts help; the install handoff remains manual. |
|
||||
| 8 | Aesthetic and Minimalist Design | 2 | Hierarchy is clear, but repeated panels, badges, uppercase metadata labels, and overlapping install actions add noise. |
|
||||
| 9 | Error Recognition and Recovery | 3 | Errors are plain-language and usually preserve work; the global action error can be detached from the failed control. |
|
||||
| 10 | Help and Documentation | 3 | The installation guide and source explanation are useful; security terminology lacks inline definitions. |
|
||||
| **Total** | | **27/40** | **Acceptable, significant improvements needed** |
|
||||
|
||||
## Anti-Patterns Verdict
|
||||
|
||||
**LLM assessment:** Moderate AI-assisted feel, not outright slop. Domain-specific copy, extensive state coverage, and transparent source/capability information make the feature credible. The generic card grid, repeated rounded panels, icon tiles, uppercase tracked labels, and decorative changelog stripe still expose a component-recipe grammar. It looks competent but not fully edited into a distinctive Homarr store.
|
||||
|
||||
**Deterministic scan:** `detect.mjs --json apps/docs/src/components/workshop` exited 0 with exact output `[]`: zero findings, zero triggered rules, and no false positives. The scan found none of Impeccable's deterministic anti-patterns.
|
||||
|
||||
**Visual overlays:** No reliable user-visible overlay is available. The independent detector agent had no browser backend. The parent fallback opened a fresh browser tab and visually inspected the listing, but the browser API did not expose a mutable evaluation method for the required preflight, so injection and `[Human]` visibility were correctly skipped. Browser/source evidence was used instead.
|
||||
|
||||
## Overall Impression
|
||||
|
||||
The Workshop is already substantially better than a generic gallery: it communicates authorship, source code, API capabilities, secrets, revision history, reports, and discussion. The biggest opportunity is to turn that information into a deliberate trust sequence. Today the interface lets the user act first and understand safety later, especially on mobile.
|
||||
|
||||
## What's Working
|
||||
|
||||
- **State coverage is unusually strong.** Listing failures, stale data, loading skeletons, empty results, filter recovery, comment errors, pending actions, and destructive confirmations have explicit UI.
|
||||
- **Pre-install transparency is excellent.** API hosts, methods, configurable options, network scope, and credential requirements are visible instead of hidden behind a download.
|
||||
- **The new primitive layer is coherent.** Search, filters, menus, switches, dialogs, and code presentation share one restrained interaction vocabulary across light and dark themes.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
### [P1] Safety context arrives after the install action on mobile
|
||||
|
||||
**Why it matters:** Download and copy actions appear in the header, while source capabilities, credential requirements, and installation guidance live in an aside that drops below the long source and discussion sections under the desktop breakpoint. A first-time mobile user can commit before understanding what the widget contacts or what secrets it needs.
|
||||
|
||||
**Fix:** Put a compact “Before you install” summary immediately below the header on small screens: API hosts, network scopes, credential count, and action/query counts. Keep the full sticky details panel on desktop.
|
||||
|
||||
**Suggested command:** `$impeccable layout`
|
||||
|
||||
### [P1] Primary button and own-comment contrast misses AA
|
||||
|
||||
**Why it matters:** The requested theme's primary red with white foreground is about 3.4:1, below 4.5:1 for small text. The same pairing affects primary buttons and the author's comment bubbles.
|
||||
|
||||
**Fix:** Darken the primary red or use a dark foreground in light mode, then verify default buttons and comment bubbles in both themes.
|
||||
|
||||
**Suggested command:** `$impeccable colorize`
|
||||
|
||||
### [P2] The listing's server-rendered fallback is blank
|
||||
|
||||
**Why it matters:** Before hydration, the listing renders an empty block instead of preserving the page's hierarchy and indicating progress. This creates a visible dead zone on slower self-hosted deployments.
|
||||
|
||||
**Fix:** Render the listing heading, toolbar silhouette, and shared skeleton cards as the `BrowserOnly` fallback, matching the detail page's loading treatment.
|
||||
|
||||
**Suggested command:** `$impeccable harden`
|
||||
|
||||
### [P2] Download, copy, source, and install compete for the same task
|
||||
|
||||
**Why it matters:** “Download widget,” “Copy JSON,” a full source viewer, and a separate installation panel all appear as parallel paths. Users must infer the recommended workflow.
|
||||
|
||||
**Fix:** Make “Download widget JSON” the single primary header action. Keep copy beside the source viewer and place the next import step directly beside or immediately after download.
|
||||
|
||||
**Suggested command:** `$impeccable clarify`
|
||||
|
||||
### [P2] Repeated metadata scaffolding creates visual noise
|
||||
|
||||
**Why it matters:** Card shells, badges, icon tiles, uppercase tracked labels, and the changelog stripe make the detail page feel assembled from recipes and slow scanning.
|
||||
|
||||
**Fix:** Flatten the widget details into a compact definition list, reserve badges for status, and remove the decorative changelog stripe.
|
||||
|
||||
**Suggested command:** `$impeccable distill`
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
**Jordan, first-timer:** “Network scope,” queries, actions, options, and the manifest schema appear without inline explanation. Download versus copy has no stated recommendation. On mobile, credential and installation context arrives after source and discussion.
|
||||
|
||||
**Sam, accessibility-dependent user:** Primary controls and author comment bubbles miss small-text contrast. Copy, optimistic votes, filter result changes, and successful report/edit completion do not expose explicit live-region behavior. Gallery controls and dots are below comfortable touch-target sizes.
|
||||
|
||||
**Casey, distracted mobile user:** The most important safety panel falls to the bottom, while primary actions stay at the top. Long source and discussion content separate the decision from its explanation. The submit type selector remains dense at narrow widths.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- The internal installation guide uses an external-link icon.
|
||||
- The final publication action says “Submit” rather than “Publish submission.”
|
||||
- Dark-theme tokens are declared in two blocks, increasing drift risk.
|
||||
- Screenshot dots rely on opacity alone and lack `aria-current`.
|
||||
- Successful report and edit actions deserve a small confirmation instead of silent dismissal.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
- If source review is important enough to say “Review exactly what will be installed,” why can users download before seeing the capability and credential summary?
|
||||
- Is Workshop primarily a code catalog or a safety-conscious app store? The current action hierarchy tries to be both.
|
||||
- Should the successful end state be a downloaded file, or a confident handoff into Homarr with the next step already visible?
|
||||
@@ -1,96 +0,0 @@
|
||||
# Changes
|
||||
|
||||
## Goal
|
||||
|
||||
Remove widget-only services from Homarr's global Integration system.
|
||||
|
||||
The affected services are release providers and Search.ch. They are only used by specific widgets, so keeping them as global integrations added unnecessary setup, UI, database rows, docs pages, and integration package code.
|
||||
|
||||
ArchiveTeam Warrior remains a proper integration but is no longer auto-seeded on first install.
|
||||
|
||||
## Why
|
||||
|
||||
These services do not need shared integration state.
|
||||
|
||||
- Release providers are selected per Releases widget repository.
|
||||
- Search.ch is only used by the Timetable widget.
|
||||
|
||||
Moving them into widget options makes configuration simpler and removes dead-weight integration code from `packages/integrations`.
|
||||
|
||||
## What Changed
|
||||
|
||||
### Release Providers
|
||||
|
||||
- Added release provider definitions in `packages/definitions/src/release-provider.ts`.
|
||||
- Removed release providers from `IntegrationKind`.
|
||||
- Moved release-fetching code out of `packages/integrations` and into `packages/request-handler/src/release-providers.ts`.
|
||||
- Updated the Releases widget to store provider configuration directly in widget options:
|
||||
- `provider`
|
||||
- `identifier`
|
||||
- `versionFilter`
|
||||
- `providerUrl`
|
||||
- Updated the Releases widget editor to use a provider select instead of an integration select.
|
||||
- Updated Docker import mapping so `ghcr.io` maps to `gitHubContainerRegistry`.
|
||||
- GitHub Container Registry now uses the public OCI registry API and accepts full image references such as `ghcr.io/homarr-labs/homarr:latest`.
|
||||
|
||||
### Timetable / Search.ch
|
||||
|
||||
- Removed Search.ch as an integration.
|
||||
- Added a Timetable widget `baseUrl` option, defaulting to `https://search.ch`.
|
||||
- Moved Search.ch request/parsing logic into `packages/request-handler/src/timetable.ts`.
|
||||
- Updated the Timetable API to receive `baseUrl` directly from widget options.
|
||||
|
||||
### ArchiveTeam Warrior
|
||||
|
||||
- Kept as a proper integration (not removed).
|
||||
- Removed `defaultUrl` from the integration definition to prevent auto-seeding on first install.
|
||||
|
||||
### Integration Cleanup
|
||||
|
||||
- Removed creators, exports, docs references, and implementation files for the deleted integrations.
|
||||
- Removed unused dependencies from `packages/integrations/package.json`.
|
||||
|
||||
### Database Migration
|
||||
|
||||
- Added `packages/db/migrations/custom/0003_remove_widget_only_integrations.ts`.
|
||||
- The migration moves old integration-linked widget config into widget options, then deletes the old widget-only integration rows.
|
||||
- Updated the earlier release widget migration to use the new `provider` field instead of `providerIntegrationId`.
|
||||
|
||||
### Docs
|
||||
|
||||
- Updated Releases widget docs to describe providers instead of integrations.
|
||||
- Updated Timetable docs to remove Search.ch integration setup.
|
||||
- Removed obsolete integration docs pages for the deleted integration kinds.
|
||||
- Updated docs sitemap and integration docs mappings.
|
||||
|
||||
### Translations
|
||||
|
||||
- Added English labels for the new widget options:
|
||||
- `widget.timetable.option.baseUrl.label`
|
||||
- `widget.releases.option.repositories.providerUrl.label`
|
||||
|
||||
## How It Works Now
|
||||
|
||||
Release provider configuration lives on each Releases widget repository entry. The request handler resolves provider metadata and fetches releases directly from the provider-specific endpoint. GHCR uses anonymous OCI registry access for public packages.
|
||||
|
||||
Timetable passes its widget URL option directly to its API router and request handler. No global integration lookup is needed.
|
||||
|
||||
ArchiveTeam Warrior continues to use the standard integration system, but users must manually add the integration (it is not auto-created on install).
|
||||
|
||||
## Deliberate Non-Goals
|
||||
|
||||
- No provider secret/token support was added.
|
||||
- No new secret storage model was introduced.
|
||||
- No compatibility layer was kept in `packages/integrations`.
|
||||
|
||||
Widget options are client-visible, so storing provider credentials there would be wrong. If authenticated provider requests are needed later, they need a separate secret-storage design.
|
||||
|
||||
## Verification So Far
|
||||
|
||||
- Focused typechecks passed for affected packages.
|
||||
- Full `pnpm turbo typecheck` passed.
|
||||
- Full `pnpm lint` passed with existing warnings.
|
||||
- Focused release provider normalization tests passed.
|
||||
- Widget translation test passed.
|
||||
- Live GHCR smoke checks passed for `ghcr.io/muchobien/pocketbase:latest` and `ghcr.io/homarr-labs/homarr:latest`.
|
||||
- `pnpm test packages/integrations/test/pi-hole.spec.ts` passed earlier, but failed on rebased reruns because Pi-hole reported `domainsBeingBlocked` as `-2`/`0` instead of `> 1`.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Homarr is for self-hosters and administrators who assemble dashboards for services across home labs and private infrastructure. They need dense, reliable controls and status at a glance, while retaining enough guidance to configure advanced features without hiding how they work.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Homarr provides one place to organize, observe, and operate self-hosted services. Success means users can build useful dashboards quickly, understand their state, recover from configuration errors, act on services, and reshape their workspace without repeatedly leaving the board. Boards must load immediately, stay visually stable, and keep widgets legible across screen sizes.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Direct, capable, precise, and approachable. Homarr should feel calm under load, be truthful about system state, and reward users who refine details.
|
||||
|
||||
## Anti-references
|
||||
|
||||
- Decorative dashboards, oversized metric cards, and motion without state meaning.
|
||||
- Wizard flows that fight ordinary scrolling or rigid layouts that force one workflow.
|
||||
- Hidden configuration magic and implementation complexity exposed before it is needed.
|
||||
- Clever custom controls where familiar platform or Mantine interactions already work.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Keep configuration, consequences, and system state visible.
|
||||
2. Make simple tasks short without limiting advanced workflows.
|
||||
3. Preserve expert depth while prioritizing urgent information at small sizes.
|
||||
4. Reveal dense workspaces progressively through a consistent advanced focus mode.
|
||||
5. Isolate failures and show loading, stale, partial, empty, and failed states truthfully.
|
||||
6. Use the existing Mantine design language consistently across management and board surfaces.
|
||||
7. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback.
|
||||
8. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Target WCAG 2.2 AA. Every pointer interaction needs a keyboard and coarse-pointer path. Support screen readers, visible and restored focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations.
|
||||
@@ -1,263 +0,0 @@
|
||||
# PR #6502 widget-modernization ultra-review
|
||||
|
||||
## Executive result
|
||||
|
||||
**Merge recommendation: changes required.** No P0 was found, but the current branch still has five P1 issues: a pre-existing Custom API authorization/redirect weakness exposed by this subsystem review, an inaccessible manual advanced-focus interaction, a likely fixed-position/transform geometry defect, systemic false empty states for failed queries, and unvalidated integration-provided navigation URLs. Eight P2 issues cover raw error disclosure, unsafe runtime-ref interfaces, localization, responsive/theming drift, test coverage, and inaccurate PR copy.
|
||||
|
||||
Audit health (Impeccable rubric): accessibility 1/4, performance 2/4, responsive design 2/4, theming 2/4, anti-patterns 3/4; **10/20 (Acceptable, significant work required)**. The UI does not look generically AI-generated; its problem is inconsistent technical behavior beneath an otherwise coherent Mantine vocabulary.
|
||||
|
||||
## Reviewed revision and environment
|
||||
|
||||
| Item | Value |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| PR | #6502, `feat: modernize dashboard widgets` |
|
||||
| Reviewed head | `96e92485a5fd45f3648cfac0453e614779c414ba` |
|
||||
| Base branch / fetched base | `dev` / `d58a47b776a55d04dee2220f59d072b9af8bbed3` |
|
||||
| Merge base | `d58a47b776a55d04dee2220f59d072b9af8bbed3` |
|
||||
| Diff | 359 files, +15,721/-4,478 |
|
||||
| PR state | open, non-draft, `BLOCKED`; no approval decision |
|
||||
| Hosted checks at audit start | Fast gate, Container and E2E, CodeQL, JS/TS analysis, Actions analysis, title, auto-fix: green |
|
||||
| Inline threads | 34 total, 7 unresolved; only 2 remain substantively valid |
|
||||
| Mantine | repository lock: `@mantine/core`, hooks, charts and dates 9.4.1 |
|
||||
| Local tools | Node 24.16.0 and pnpm 9.5.0; repository requires Node >=24.18.0 and pnpm >=11.15.1 |
|
||||
|
||||
The former red baseline `7b7045202d289b7d83946befcee5d2845ea4a706` had a green Fast gate and a failed Container/E2E run (`30748255760`, job `91498308523`). The latest head has a green Container/E2E job (`30756067198`, job `91519039504`). This proves the current suite passed once; it does not satisfy the requested three-run flake characterization.
|
||||
|
||||
## PR-description traceability
|
||||
|
||||
| Claim | Result | Evidence / correction |
|
||||
| --------------------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| “all 54 widget renderers with compact and advanced modes” | **Inaccurate** | 54 `WidgetKind` values exist. `displayMode="advanced"` reaches 53; `app` is deliberately excluded in `item-content.tsx:51` and documented as compact-only. Change copy to “53 widget renderers; app tiles remain compact,” or implement app advanced mode. |
|
||||
| advanced focus: hover, keyboard, touch, geometry | **Partial** | Entry paths, Escape ownership, reduced motion, close focus, touch button and zero-dimension guards exist. WID-002/003 remain. |
|
||||
| richer widget context actions | **Partial** | `Menu.ContextMenu` is a good Mantine choice and query scope is item-aware. Dispatch still crosses `any`; Custom API action authorization is not represented. |
|
||||
| standardized query identity/cache/status | **Partial** | Query keys, matchers, runtime capture, recovery and tests exist. Thirty-nine of 53 renderer query calls do not read errors; runtime ref registration is render-time mutable state. |
|
||||
| safe polling | **Mostly confirmed** | defaults are limited to `widget` namespace; Custom API resume immediately refetches; per-widget intervals exist. State/ref typing still needs redesign. |
|
||||
| partial-failure rendering | **Partial** | settlement helper and several routers are strong; many renderer-level total failures still appear empty/disconnected. |
|
||||
| bounded integration responses | **Mostly confirmed** | common bounded reader, Custom API 2 MiB, timetable 512 KiB, Traefik bounds and Beszel queue bounds exist. Custom action execution remains outside the hardened fetch path. |
|
||||
| timetable hardening | **Confirmed** | board/item permission checks, saved-value comparison, public-address resolution, private ranges, per-request DNS re-resolution, pinned dispatcher, redirect rejection, timeouts, response bounds and schemas are present. |
|
||||
| precise placement | **Mostly confirmed** | mouse, touch tolerance, keyboard, occupied-cell validation, dynamic bounds, nearest fit and immediate persistence exist. Failure/race/live responsive coverage remains insufficient. |
|
||||
| widget catalog documentation | **Partial** | all kinds have pages and shared interactions; wording overclaims focus behavior and validation counts are stale. |
|
||||
| validation: 40 files/162 tests, 6/6 E2E | **Stale** | current diff contains 57 changed unit-spec files (including three E2E specs); repository has 42 widget spec files and six E2E specs. Update from the exact final run. |
|
||||
| independent reviews “pass” | **Not supported** | unresolved valid findings, current P1s, and unavailable live QA contradict an unqualified pass. |
|
||||
|
||||
## Mantine suitability ledger
|
||||
|
||||
Legend: **U** already used appropriately; **C** consolidation/replacement candidate; **R** relevant but current implementation can stay; **N** not relevant to widget runtime; **D** dependency/version addition. Exact 9.4.1 package archives were inspected in `/tmp`; live docs/Context7 include 9.5.0.
|
||||
|
||||
### Core catalog
|
||||
|
||||
- **U:** ActionIcon, Alert, Anchor, Avatar, Badge, Box, Button, Card, Center, Code, Divider, Flex, Grid, Group, Image, Loader, Menu (`Menu.ContextMenu`), Overlay, Paper, Popover, Portal, Progress, RingProgress, ScrollArea, SimpleGrid, Skeleton, Stack, Table, Tabs, Text, ThemeIcon, Title, Tooltip, UnstyledButton.
|
||||
- **C:** Accordion (dense integration sections), DataList (semantic label/value stats), EmptyState (replace bespoke empty state), FocusTrap and Modal compound API (WID-002), LoadingOverlay (background refetch without deleting content), NumberFormatter, OverflowList/Scroller (responsive action and chip rows), Table.ScrollContainer, Timeline (activity/feed histories), VisuallyHidden and Kbd, CloseButton, Dialog/HoverCard/Notification where persistent errors/actions need semantics.
|
||||
- **R:** Affix, AppShell, AspectRatio, BackgroundImage, Breadcrumbs, Burger, Checkbox, Chip, Collapse, ColorInput/ColorPicker/ColorSwatch/AlphaSlider/AngleSlider/HueSlider, Combobox/ComboboxPopover, Container, CopyButton, Drawer, Fieldset, FileButton/FileInput, FloatingIndicator/FloatingWindow, Highlight, Indicator, Input/JsonInput, List, Mark, Marquee, MaskInput, Menubar, MultiSelect/NativeSelect/NumberInput/PasswordInput/PillsInput/PinInput/Radio/RangeSlider/Rating/SegmentedControl/Select/Slider/Switch/TagsInput/Textarea/TextInput, Pagination, Pill, SemiCircleProgress, Space, Splitter, Spoiler, Stepper, Transition, Tree/TreeSelect, Typography. Use only where an existing widget requirement matches; do not churn working layouts.
|
||||
- **N:** Alpha/color/angle primitives outside configuration forms, AppShell/navigation primitives, TableOfContents, CorePackage documentation.
|
||||
- **D (9.5, not 9.4.1):** Cascader. **RollingNumber and Splitter are present in 9.4.1.**
|
||||
|
||||
### Hooks catalog
|
||||
|
||||
- **C:** `useFocusReturn`, `useFocusTrap`, `useReducedMotion`, `useRovingIndex`, `useLongPress`, `useScroller`, `useElementSize`, `useViewportSize`, `useHotkeys`, `useMediaQuery`, `useResizeObserver`.
|
||||
- **U/R:** focus-within, hover, disclosure, click-outside, merged-ref, interval/timeout, local-storage, event/window listeners, throttled/debounced values and list/set/map/state helpers are available in 9.4.1 and should replace local mechanics only when behavior and tests improve.
|
||||
- **N:** document title/favicon/hash/headroom/page-leave/OS/eye-dropper/fullscreen/network/orientation and other page-level utilities have no direct widget-remediation need. Every hook listed under `## Hooks` in `MANTINE.md` is covered by this rule; no hook requires 9.5 for the recommendations above.
|
||||
|
||||
### Form, dates, charts, schedule, extensions, theme and styles
|
||||
|
||||
- **Form:** existing `@homarr/form` should remain the abstraction over all cataloged form APIs. No widget-runtime replacement; use current validation/status APIs for widget editors (**R**).
|
||||
- **Dates:** all catalog entries are in installed 9.4.1 dates. `Calendar`, `MiniCalendar`, and `TimeValue` are candidates; other picker/input entries are editor-only (**R**). Importing dates directly into `@homarr/widgets` requires adding that workspace dependency (**D**).
|
||||
- **Charts:** 9.4.1 exports Area, Bar, BarsList, Bubble, Composite, Donut, Funnel, Heatmap, Line, Pie, Radar, RadialBar, Sankey, Scatter, Sparkline and Treemap (**C/R**). Prefer Sparkline for tiny trends, BarsList for ranked values, Heatmap for calendar/activity density, Composite for mixed analytics. **BulletChart and SunburstChart are 9.5-only (D)**. Replacement requires accessibility and bundle comparison, not blanket Recharts removal.
|
||||
- **Schedule:** all `AgendaView`, day/week/month/year/resource views and Schedule pages require new `@mantine/schedule` dependency (**D**). AgendaView is the only plausible calendar-widget candidate; its weight and semantics must beat the existing grouped list before adoption.
|
||||
- **Guides/FAQ/Other:** server components, portal testing, scroll lock, accessibility, nested popovers, zoom, responsive styling and version changelogs are relevant references (**R**); framework/setup/contribution/old migration entries are **N**. The 9.5 changelog is informational only.
|
||||
- **Theming/styles:** Colors, ColorSchemes, DefaultProps, MantineProvider, ThemeObject, CSS variables, data attributes, CSS Modules, responsive/RTL/style props/Styles API/performance are **U/R**. WID-009 requires shared layer/color tokens. Emotion, Sass, Vanilla Extract and unrelated global setup are **N**.
|
||||
- **Extensions:** existing modals, notifications, spotlight and rich-text abstractions are **U**; Carousel could consolidate Immich only after behavior parity; Dropzone/CodeHighlight/NProgress are **N** for this audit.
|
||||
|
||||
## Complete 54-widget coverage matrix
|
||||
|
||||
“Test” means a focused widget-local spec exists, not that all required states are covered. Every row still requires the common contract suite in WID-013.
|
||||
|
||||
| WidgetKind | Family | Advanced | Focused test | Principal audit gap |
|
||||
| --------------------------- | ----------------------- | ----------- | ------------ | ---------------------------------------------------------- |
|
||||
| clock | basic | yes | no | query error, locale-format options |
|
||||
| weather | basic/feed | yes | yes | query error; dayjs locale/time patterns |
|
||||
| app | basic/link | **no** | no | PR wording; URL contract |
|
||||
| iframe | custom content | yes | yes | manual focus/iframe trapping; URL privacy thread now fixed |
|
||||
| video | custom content | yes | no | media/focus/live behavior |
|
||||
| notebook | notes | yes (inner) | yes | editor focus inside manual surface |
|
||||
| anchorNote | notes | yes | no | query error, save/error regression |
|
||||
| dnsHoleSummary | network | yes | no | total query error, hard-coded chart colors |
|
||||
| dnsHoleControls | network/security | yes | no | total error and permission matrix |
|
||||
| smartHome-entityState | smart home | yes | no | raw state in aria; action permissions |
|
||||
| smartHome-executeAutomation | smart home | yes | no | mutation/permission/edit-mode E2E |
|
||||
| stockPrice | analytics | yes | yes | total error |
|
||||
| mediaServer | media | yes | no | total error and table a11y |
|
||||
| calendar | calendar | yes | yes | total error; fixed HH:mm; schedule tradeoff |
|
||||
| downloads | media | yes | yes | four-state/component coverage, arbitrary z-index |
|
||||
| mediaRequests-requestList | media | yes | no | total error; external URLs/actions |
|
||||
| mediaRequests-requestStats | media | yes | no | total error; links/colors |
|
||||
| mediaTranscoding | media | yes | yes | total error and responsive table |
|
||||
| mediaMissing | media | yes | yes | total error and external URL |
|
||||
| minecraftServerStatus | infrastructure | yes | no | total error/malformed payload |
|
||||
| networkControllerSummary | networking | yes | no | total error; WAN/unknown localization |
|
||||
| networkControllerStatus | networking | yes | no | responsive rule duplication |
|
||||
| rssFeed | feed | yes | yes | strong stale/partial state; locale relative time |
|
||||
| bookmarks | basic/link | yes | yes | recomputation; URL contract |
|
||||
| indexerManager | media | yes | no | total error; runtime action ref |
|
||||
| healthMonitoring | infrastructure | yes | yes | integration query error |
|
||||
| releases | infrastructure/feed | yes | yes | secret/item authorization needs E2E |
|
||||
| mediaReleases | media | yes | no | total error; external URLs |
|
||||
| dockerContainers | infrastructure | yes | yes | total error; z-index/table semantics |
|
||||
| firewall | networking/security | yes | yes | partial-query matrix; loose router generics |
|
||||
| notifications | infrastructure | yes | no | total error; untrusted href |
|
||||
| systemResources | infrastructure | yes | yes | total error; previous network thread fixed |
|
||||
| coolify | infrastructure | yes | yes | total error; URL validation |
|
||||
| systemDisks | infrastructure | yes | yes | total error |
|
||||
| timetable | calendar | yes | no | backend strong; component locale/live tests absent |
|
||||
| immich-serverStats | media | yes | yes | two ignored query errors |
|
||||
| immich-albumCarousel | media | yes | yes (query) | query error and nested overlay/focus E2E |
|
||||
| paperlessNgx | analytics | yes | no | former error thread fixed, no regression spec |
|
||||
| patchmon | infrastructure | yes | yes | total error |
|
||||
| bazarr | media | yes | yes | total error |
|
||||
| tracearr | analytics | yes | yes | total error |
|
||||
| speedtestTracker | analytics | yes | yes | total error |
|
||||
| uptimeKuma | infrastructure | yes | yes | total error |
|
||||
| audioStats | media | yes | yes | good dual-error handling; partial-state tests needed |
|
||||
| umami | analytics | yes | no | runtime query registration; chart a11y |
|
||||
| vpn | networking/security | yes | no | total error; status/permission matrix |
|
||||
| archiveTeamWarrior | infrastructure | yes | yes | suspense path; docs present |
|
||||
| ups | infrastructure | yes | no | total error |
|
||||
| beszelSystemTable | monitoring | yes | yes | nested chart/focus and live subscription |
|
||||
| beszelSystemGrid | monitoring | yes | no | live/error UI; no focused renderer test |
|
||||
| beszelAlerts | monitoring | yes | no | partial error and status semantics |
|
||||
| beszelSystemStats | monitoring | yes | yes | runtime refs, locale tooltip |
|
||||
| traefik | networking | yes | no | total error; bounded backend positive |
|
||||
| customApi | custom content/security | yes | yes | WID-001/007/008; polling resume fixed |
|
||||
|
||||
## Severity-ranked findings
|
||||
|
||||
### WID-001 — P1 confirmed, pre-existing: Custom API actions lack resource authorization and hardened fetch policy
|
||||
|
||||
- **Location/claim:** `packages/api/src/router/custom-widget/custom-widget-router.ts:57-102,392-451`; “harden Custom API” and action-permission semantics.
|
||||
- **Evidence/reproduction:** any authenticated account can call `customWidget.all`, learn every definition ID and URL, then call `customWidget.execute({definitionId})`. The mutation verifies neither board nor item nor an action permission. It decrypts administrator-provided secrets, uses `new URL()` only, and `redirect: "follow"`. The PR hardened `widget.customApi.getData`, but not this sibling execution path.
|
||||
- **Impact/rule:** unauthorized side effects against internal services and authenticated redirect chains; broken object-level authorization. A view-only account can invoke administrator-created controls outside any board context.
|
||||
- **Correction:** require `{boardId,itemId,definitionId}`; load the item and verify it is `customApi`, belongs to the board, references the definition, and the caller has an explicit execute policy. Until that policy exists, restrict to admin. Share `validateCustomApiUrl`, timeout, redirect rejection, bounded disposal and redacted errors with getData. Compatibility: old direct callers must send item/board context.
|
||||
- **Tests:** procedure tests for anonymous, unrelated authenticated, view-only, modifier, admin, mismatched item/definition, disabled definition, redirects, credential URL, timeout and secret non-disclosure; E2E action button across roles.
|
||||
|
||||
### WID-002 — P1 confirmed, PR-introduced: manual advanced focus is not an accessible modal
|
||||
|
||||
- **Location:** `advanced-focus/context.tsx:194-209`, `items/item-content.tsx:113-189`.
|
||||
- **Evidence:** manual mode renders an Overlay plus a `role="region"` Card. There is no FocusTrap, `aria-modal`, background inertness, scroll lock or backdrop dismissal. Only the close button is initially focused; Tab can leave the surface and activate dimmed board controls.
|
||||
- **Impact/rule:** keyboard/screen-reader users lose the interaction boundary; WCAG 2.1.2, 2.4.3 and ARIA dialog pattern. Iframes/editors make escape especially likely.
|
||||
- **Correction/Mantine:** render manual mode through `Modal.Root` compound parts (or ModalBase), using its portal, FocusTrap, scroll lock, Escape/return-focus and semantic content. Preserve preview mode as non-modal. Configure nested overlays and `removeScrollProps`; keep a 44px `Modal.CloseButton` and explicit title via VisuallyHidden if needed.
|
||||
- **Tests:** RTL portal/focus-cycle/return tests; Playwright Tab/Shift+Tab, nested Menu/Select/Popover/editor/iframe, backdrop and every close path; reduced motion and 200% zoom.
|
||||
|
||||
### WID-003 — P1 probable, PR-introduced: fixed advanced surface remains under transformed Gridstack ancestry
|
||||
|
||||
- **Location:** `advanced-focus.module.css:35-42`; Card remains inside the Gridstack item while only the backdrop is portaled.
|
||||
- **Evidence:** CSS fixed descendants establish their containing block at a transformed ancestor. Gridstack uses transforms for positioned items, so viewport-derived `left/top` can be interpreted relative to the item on affected layouts. A Portal wrapper exists only for Overlay.
|
||||
- **Impact:** off-screen, clipped or incorrectly scaled focus surface across breakpoints/remounts.
|
||||
- **Correction:** portal the focused surface itself (naturally achieved by WID-002) and calculate geometry in the same viewport coordinate system. Do not compensate with transform-specific offsets.
|
||||
- **Tests:** real Gridstack at every layout/viewport, scrolled page, header, dynamic section and resize/remount; assert bounding rect within viewport and finite scale.
|
||||
|
||||
### WID-004 — P1 confirmed, regression-exposed/systemic: query failures still become valid empty/disconnected UI
|
||||
|
||||
- **Location:** 39 of 53 renderer query calls do not read `error`, including bookmarks, weather, calendar, media server, system resources, Coolify, DNS Hole, Tracearr, notifications, Immich, VPN and others listed in the matrix. Query defaults do not set `throwOnError` (`trpc.tsx:69-93`).
|
||||
- **Evidence:** TanStack Query returns `data=undefined` on total failure. Components default to `[]`, `null`, or empty branches, so the board ErrorBoundary is never reached. Network Controller and Paperless were fixed, proving the pattern; it was not completed.
|
||||
- **Impact:** operators are told “empty” or “disconnected” when monitoring failed, violating the product’s truthful-state requirement.
|
||||
- **Correction/Mantine:** introduce a shared `WidgetQueryState` contract distinguishing initial Skeleton, background LoadingOverlay, stale Alert, partial warning, true EmptyState and terminal error. Either explicitly throw only when no usable cached data or render a shared error Alert. Preserve stale data.
|
||||
- **Tests:** parameterized contract tests for every queried widget: initial, success-empty, total failure, stale+failure, partial failure, refetch, remount/persisted cache and sibling isolation.
|
||||
|
||||
### WID-005 — P1 confirmed, PR-introduced/systemic: integration-provided links bypass a shared URL policy
|
||||
|
||||
- **Location:** examples include `media-missing/component.tsx:291`, `notifications/component.tsx:77`, media requests list/stats, Coolify resource/server links, releases, bookmarks and iframe. Only RSS and custom JSX visibly use explicit protocol sanitizers.
|
||||
- **Evidence:** dynamic strings are assigned directly to `href`/new-tab controls. Integration APIs and remote feeds are trust boundaries; no renderer-wide `http/https` guard is enforced.
|
||||
- **Impact:** unsafe schemes, credential-bearing URLs, internal detail disclosure and inconsistent opener behavior.
|
||||
- **Correction:** add one typed application URL helper returning a sanitized URL/undefined, with allowed schemes per use case, credential stripping/rejection, consistent `noopener noreferrer`, and disabled non-link presentation. Validate again server-side for action endpoints.
|
||||
- **Tests:** `javascript:`, `data:`, malformed, credential, relative, http/https and missing URLs for each shared link primitive.
|
||||
|
||||
### WID-006 — P2 confirmed, PR-introduced: runtime query/action state mutates untyped refs during render
|
||||
|
||||
- **Location:** `definition.ts:121-162`; runtime registration calls in media missing/transcoding, audio, calendar, Umami, Beszel and Immich; `widget-context-menu.tsx:157-174`.
|
||||
- **Evidence:** `setWidgetRuntimeQueries` mutates a shared object during render; concurrent/aborted renders can publish uncommitted query identity. Action dispatch casts the definition to `Record<string,unknown>` and `any`, then casts its result back.
|
||||
- **Correction:** make definition generics preserve kind/options/action types end-to-end; replace stringly opaque ref slots with a typed per-item registration API updated in a committed effect (or use `useSyncExternalStore`). Separate query registration from imperative actions.
|
||||
- **Tests:** StrictMode, interrupted/remount, options change and cleanup; compile-time `tsd`/type assertions rejecting wrong action props.
|
||||
|
||||
### WID-007 — P2 confirmed, regression-exposed: raw backend errors are exposed to users and assistive text
|
||||
|
||||
- **Location:** Custom API UI `component.tsx:561-576`; API `custom-api.ts:79-85`; several partial fallbacks use `error.message`.
|
||||
- **Impact:** internal hostnames, request paths and implementation details can appear in tooltips/aria labels and logs.
|
||||
- **Correction/Mantine:** return stable error codes plus redacted public messages; keep detailed causes server-side with correlation IDs. Render localized Alert/Notification details, never raw throwable text.
|
||||
- **Tests:** errors containing credentials, query strings, private hosts and multiline backend bodies must not reach tRPC payload, DOM, accessibility tree or notifications.
|
||||
|
||||
### WID-008 — P2 confirmed, PR-introduced/regression-exposed: locale handling remains incomplete
|
||||
|
||||
- **Locations:** Beszel chart/tooltip dayjs formats, calendar event `HH:mm`, downloads `YYYY-MM-DD HH:mm`, timetable `HH:mm:ss`, weather day/month/time formats; `_inputs/widget-location-input.tsx:199` hardcodes `Intl.NumberFormat("en")`.
|
||||
- **Also:** `beszel/_shared/disk-usage.tsx:41`, stats loading aria label, Network Controller `WAN`/`unknown` are English/raw accessible strings.
|
||||
- **Correction:** centralize app-locale date/time/number/duration/status helpers; load canonical dayjs locale only at the boundary; use localized translation keys for aria/status. NumberFormatter is suitable for simple display but application helpers should own locale.
|
||||
- **Tests:** at least `en-US`, `fr-FR`, `de-DE`, RTL locale, 12/24-hour preference and long translations, including accessibility names.
|
||||
|
||||
### WID-009 — P2 confirmed: layer and color tokens are inconsistent
|
||||
|
||||
- **Locations:** downloads z-index 1000 and rgba row colors; item title z-index 4; item menu/dynamic menu 10; media server white rgba; Indexer Manager hex colors; DNS Hole rgba palette; Umami hex tick colors.
|
||||
- **Correction:** define shared semantic layer tokens relative to Mantine CSS z-index variables and semantic status/chart tokens respecting light/dark/forced colors. Do not replace intentional service branding.
|
||||
- **Tests:** light/dark/forced-colors with nested overlays and tooltip ordering.
|
||||
|
||||
### WID-010 — P2 confirmed: responsive decisions are duplicated and under-tested
|
||||
|
||||
- **Location:** Network Controller Status `component.tsx:44,59-78` (open valid thread); Bookmark `component.tsx:94` recomputation (valid but low-impact thread); similar width/height literals across renderers.
|
||||
- **Correction:** small typed breakpoint lookup helpers per family, memoized only for genuinely expensive layout. Prefer container-query Grid/SimpleGrid where structural. Do not cargo-cult `useMemo` for a cheap pure calculation; benchmark Bookmark before accepting the review suggestion.
|
||||
- **Tests:** boundary tables for minimum/typical/wide/short/advanced sizes and 200% zoom.
|
||||
|
||||
### WID-011 — P2 confirmed: PR/docs overstate behavior and validation
|
||||
|
||||
- **Location:** PR body, board/getting-started copy “advanced preview in place,” validation numbers, and unqualified review pass.
|
||||
- **Correction:** state 53 advanced renderers plus compact-only app; call manual focus an overlay only after WID-002/003; publish exact final-SHA check/test counts and disclose unsupported/live-untested states.
|
||||
|
||||
### WID-012 — P2 confirmed: focused automated coverage is far below the 54-widget/data-state claim
|
||||
|
||||
- **Evidence:** 42 widget-local spec files are helper-heavy; 20+ renderers have no focused spec. Six E2E files exist, but only board interactions and Custom JSX directly exercise this modernization. The earlier Custom JSX login wait failed once and current exact-route wait remains.
|
||||
- **Correction:** add shared renderer contract harness plus family E2Es. Replace Custom JSX’s exact post-submit URL race with a shared login helper that waits for authenticated session/stable destination, then run it three times in CI.
|
||||
|
||||
### WID-013 — P2 confirmed: open review-thread state no longer matches code
|
||||
|
||||
| Thread | Classification |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| iframe complete URL | already fixed/false positive at current line: only host is rendered |
|
||||
| system resources network gate | already fixed, outdated |
|
||||
| RSS total error | already fixed: no-data error throws; stale data retained |
|
||||
| bookmarks memoization | valid P3/P2-low; benchmark first |
|
||||
| network-controller total error | already fixed, outdated |
|
||||
| network-controller breakpoint consolidation | valid P2 maintainability |
|
||||
| Paperless total error | already fixed, outdated |
|
||||
|
||||
Resolve the five stale threads with evidence; address or explicitly disposition the two live suggestions. Re-run review because CodeRabbit was paused during active development.
|
||||
|
||||
## Systemic remediation roadmap
|
||||
|
||||
1. **Security/authorization:** WID-001 and WID-005; add negative-role/protocol tests before merge.
|
||||
2. **Interaction accessibility:** replace manual focus with portaled Mantine modal semantics (WID-002/003), then test nested widget content.
|
||||
3. **Truthful data state:** build the shared query-state contract and migrate all 39 ambiguous sites; keep valid stale data and partial results.
|
||||
4. **Typed interfaces:** replace `widgetStateRef` slot mutation and `any` context dispatch with committed typed registrations.
|
||||
5. **Widget families:** media/downloads, infrastructure, networking/security, smart-home, analytics/calendar/feed, then Beszel; apply URL, state, locale, responsive and a11y contracts in that order.
|
||||
6. **Design-system cleanup:** semantic layers/colors and restrained Mantine consolidation. Do not add Schedule or upgrade to 9.5 without a separate dependency/bundle decision.
|
||||
7. **Docs/PR:** correct renderer count, behavior and exact validation evidence.
|
||||
8. **Final proof:** frozen install on supported Node/pnpm; format, lint, full typecheck, affected and full unit tests, production and docs builds; Custom JSX three times; complete production-container E2E; desktop/tablet/mobile keyboard/touch/light/dark/reduced-motion/zoom/a11y-tree QA. Push nothing until all refer to one unchanged SHA; close every review thread and obtain required approval.
|
||||
|
||||
## Positive findings
|
||||
|
||||
- Timetable is the strongest security work in the PR: saved resource authorization, private-range rejection, DNS rebinding resistance through pinned lookup, redirect rejection, bounds and schemas are coherently tested.
|
||||
- Bounded response helpers, Beszel bounded queue/cancellation, and partial-integration settlement materially reduce failure blast radius.
|
||||
- RSS preserves stale data and distinguishes partial feed failure; Network Controller and Paperless now correctly escalate total failures.
|
||||
- Item-scoped query matchers and tests address sibling-widget refresh isolation; global polling defaults are restricted to the widget namespace.
|
||||
- Touch long-press has movement tolerance; focus scale guards prevent Infinity/NaN; reduced-motion and remount cleanup are present.
|
||||
- Existing Homarr theme, Mantine components and shared UI vocabulary are generally preserved. Most proposed Mantine work is consolidation, not redesign.
|
||||
|
||||
## Validation evidence and uncertainty
|
||||
|
||||
- `MANTINE.md` was fetched from `https://mantine.dev/llms.txt` and passed byte-for-byte `cmp`; `llms-full.txt` stayed in `/tmp`.
|
||||
- Context7 current Mantine docs were queried for overlay/focus, responsive/data-state/accessibility, charts and Schedule. Static 9.4.1 archives verified exact exports; Cascader, BulletChart and SunburstChart are 9.5-only.
|
||||
- Local frozen install could not start because host Node/pnpm are below repository engine requirements. No dependencies, source files or machine toolchain were changed.
|
||||
- Docker API access is denied, so production-container E2E could not be rerun locally. Hosted exact-head Fast gate and Container/E2E are green.
|
||||
- `agent-browser` is not installed. Temporary `npx` execution was rejected by the environment’s security reviewer, so live exploratory QA/a11y-tree inspection was not performed.
|
||||
- External integrations were not available; malformed/partial/permission combinations are static-analysis conclusions unless a focused test already exists.
|
||||
- This review intentionally changes no production code and makes no commit, push, thread resolution or PR edit.
|
||||
|
||||
Finalization must re-query the PR head/checks/threads. If the head differs from the reviewed SHA, this report is a snapshot and the delta requires review before merge.
|
||||
|
||||
**Final refresh (2026-08-03):** head remained `96e92485a5fd45f3648cfac0453e614779c414ba`; all reported hosted checks remained successful; merge state remained `BLOCKED` with no approval decision; unresolved threads remained 7 of 34. The branch did not change during the audit.
|
||||
@@ -34,8 +34,10 @@ Images built by this CLI record their source checkout, revision, and optional pu
|
||||
## Run images
|
||||
|
||||
```bash
|
||||
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
|
||||
homarr run <name>
|
||||
homarr run --pr <number>
|
||||
```
|
||||
|
||||
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
||||
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
|
||||
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
||||
@@ -60,70 +60,6 @@ default data; use `pnpm db:seed` to seed it explicitly later.
|
||||
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
|
||||
[Workshop development guide](../../workshop/development).
|
||||
|
||||
### Scaffold a native feature
|
||||
|
||||
Run `pnpm generate:feature` and choose `native-integration`, `widget`, or `integration-widget`. The generator creates
|
||||
implementation, focused test, documentation, and English translation skeletons and updates the current explicit
|
||||
registries. It stops before writing if a target exists, a registry anchor is ambiguous, or a supported integration is
|
||||
unknown.
|
||||
|
||||
The generated code is a starting contract, not a finished feature. Replace the placeholder request, UI, tests, secrets,
|
||||
categories, configuration, error states, and documentation with behavior specific to the service. Then run:
|
||||
|
||||
```sh
|
||||
pnpm check:feature-contracts
|
||||
pnpm test:feature-platform
|
||||
```
|
||||
|
||||
The contract check reports the exact registry, docs path, translation, support mapping, or package export that needs
|
||||
repair. It intentionally follows today's explicit registries; a future canonical feature descriptor can replace those
|
||||
patches without changing the contributor-facing generator names.
|
||||
|
||||
Integration-capable widgets declare their server-safe relationship in `nativeFeatureCapabilities`. Homarr derives the
|
||||
widget-to-integration map, reverse integration-to-widget map, optional-connection set, and server selection limits from
|
||||
that descriptor. Widget definitions still mirror the supported list for client-side type inference; the focused widget
|
||||
manifest test and `check:feature-contracts` prevent drift until those definition types can consume the descriptor
|
||||
directly.
|
||||
|
||||
New integration skeletons also include response fixtures using `simulateResponseContractAsync`. Replace the generated
|
||||
minimal schema and fixtures with representative upstream payloads: at least one successful response, important optional
|
||||
or version-specific shapes, and a malformed response that must be rejected. The simulator uses the real parser without
|
||||
network access and reports every fixture mismatch, so parser contracts can be reviewed before credentials are available.
|
||||
|
||||
### Review native feature states
|
||||
|
||||
In a development server, administrators can open `/manage/tools/feature-workbench` to inspect the shared `CatalogItem`
|
||||
and integration response-contract fixtures. The workbench uses the application Mantine theme and the same segmented
|
||||
fixture controls as the custom-widget preview. It covers ready, setup-required, standalone, connection-error, selected,
|
||||
loading, disabled, parser-success, and parser-failure states without requiring a live service.
|
||||
|
||||
Use the workbench after changing shared catalog presentation or generated parser contracts, then run the focused test:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run "apps/nextjs/src/app/[locale]/manage/tools/feature-workbench/_feature-state-workbench.spec.tsx"
|
||||
```
|
||||
|
||||
The workbench is deliberately development-only and is not linked from management navigation. It does not yet exercise
|
||||
real integration branding, long translated labels, right-to-left text, high-contrast screenshots, touch interaction,
|
||||
small viewport clipping, live network latency, retry transitions, or service-specific response schemas. Those remain
|
||||
manual review items until screenshot and browser-automation coverage is added.
|
||||
|
||||
### Check one native feature
|
||||
|
||||
Run `pnpm check:feature <kind>` before submitting an integration or widget change. The command resolves the kind through
|
||||
`integrationDefs`, `widgetKinds`, the widget manifest, creator registry, documentation slug maps, and capability
|
||||
descriptor. When an integration and widget share a kind, such as `wud`, both sides are checked together.
|
||||
|
||||
```sh
|
||||
pnpm check:feature wud --plan
|
||||
pnpm check:feature wud
|
||||
```
|
||||
|
||||
`--plan` (or `--dry-run`) prints the deterministic affected files and commands without executing them. The normal mode
|
||||
runs feature contracts, nearby fixture and unit tests, affected package typechecks, and lint and formatting for only the
|
||||
resolved files. Unknown kinds fail before any command runs. Live-service, container, and visual checks are printed as
|
||||
optional and are never executed implicitly because they may require credentials, images, or a running development app.
|
||||
|
||||
### Useful commands
|
||||
|
||||
- `pnpm dev` — Next.js on port 3000.
|
||||
@@ -140,8 +76,6 @@ optional and are never executed implicitly because they may require credentials,
|
||||
- `pnpm db:seed` — seed the default database data explicitly.
|
||||
- `pnpm db:studio` — inspect the database.
|
||||
- `pnpm format` / `pnpm format:fix` — check or apply oxfmt.
|
||||
- `pnpm generate:feature` — scaffold a native integration, widget, or paired feature.
|
||||
- `pnpm check:feature-contracts` — validate native feature registries, docs, translations, support maps, and exports.
|
||||
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
|
||||
- `pnpm typecheck` — typecheck all workspaces.
|
||||
- `pnpm test` — unit and contract suites.
|
||||
|
||||
@@ -35,7 +35,7 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
|
||||
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
|
||||
| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` |
|
||||
| `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_WEB_URL` | Public Workshop website used by links in Homarr, Custom Widgets, onboarding, and assistant context. | 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).
|
||||
@@ -58,12 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
## Docker
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
||||
| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma-separated mounted Docker-compatible socket paths. Homarr discovers each path as a selectable Docker environment | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| `DOCKER_HOSTNAMES` | Comma-separated plaintext TCP hostnames. Pair each hostname with the port in the same position in `DOCKER_PORTS` | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma-separated plaintext TCP ports paired with `DOCKER_HOSTNAMES` | For example `2375,2376` | - |
|
||||
| `DOCKER_ENDPOINTS` | Optional advanced JSON configuration for custom names, TLS, or restricted capabilities. It takes precedence over the simple variables above. See the [Docker and Podman connection guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -6,22 +6,56 @@ tags:
|
||||
|
||||
Homarr has the following keyboard shortcuts:
|
||||
|
||||
| Shortcut | Description |
|
||||
| ----------------------------------------------- | ------------------------------------------ |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Closes an open advanced view or board menu |
|
||||
| Shortcut | Description |
|
||||
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MOD` + `K` | Opens the search bar |
|
||||
| `MOD` + `J` | Toggles Dark Mode |
|
||||
| `MOD` + `E` | Toggles Edit Mode |
|
||||
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
|
||||
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
|
||||
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
|
||||
|
||||
## Widget advanced view
|
||||
|
||||
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press
|
||||
`Shift` + `Enter`, or use its menu, to keep the advanced view open.
|
||||
|
||||
## Board editing
|
||||
|
||||
`MOD` + `E` enters edit mode. Homarr keeps the current board visible while the editor prepares; movement and resizing become available when it is ready.
|
||||
|
||||
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.
|
||||
|
||||
On touch screens, press and hold a tile before moving it; a quick tap does not start a drag. Resize handles accept direct touch input. During a pointer or touch move or resize, `Esc` cancels the interaction and restores the layout from before it started.
|
||||
|
||||
## 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.
|
||||
|
||||
Advanced view is available outside edit mode. Widgets without a distinct advanced experience adapt to their normal
|
||||
dimensions instead. A progress ring follows the pointer while the temporary advanced view is preparing to open.
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
@@ -85,8 +85,8 @@ To do this, enter the board settings:
|
||||
|
||||

|
||||
|
||||
Scroll down a bit and click on custom CSS.
|
||||
Here you can enter your CSS code, which will be applied on the respective board page.
|
||||
Scroll to the **Custom CSS** section on the board settings page. It is part of the unified settings form; enter your CSS
|
||||
there and save the board settings to apply it to that board.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -6,10 +6,16 @@ tags:
|
||||
- Configuration
|
||||
---
|
||||
|
||||
import manageBoardHeaderEditMode from "./img/manage-board-header-edit-mode.png";
|
||||
import manageBoardHeaderAddItem from "./img/manage-board-header-add-item-menu.png";
|
||||
import manageBoardHeaderChooseItem from "./img/manage-board-header-choose-item.png";
|
||||
import manageBoardNoApp from "./img/manage-board-no-app.png";
|
||||
import currentKeyboardTileEditing from "../management/boards/img/keyboard-tile-editing.png";
|
||||
import boardDesktopCurrent from "./img/onboarding-board-desktop-current.png";
|
||||
import boardEditModeCurrent from "./img/onboarding-board-edit-mode-current.png";
|
||||
import addContentMenuCurrent from "./img/onboarding-add-content-menu-current.png";
|
||||
import chooseItemCurrent from "./img/onboarding-choose-item-current.png";
|
||||
import editItemCurrent from "./img/onboarding-edit-item-current.png";
|
||||
import boardsHomeMobileMenu from "./img/onboarding-boards-home-mobile-menu.png";
|
||||
import moveResizeMenuCurrent from "./img/onboarding-move-resize-menu-current.png";
|
||||
import currentBoardMobileView from "./img/onboarding-board-mobile-current.png";
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -72,9 +78,25 @@ Public boards are also accessible for users, that are not logged in ([see access
|
||||
|
||||

|
||||
|
||||
Congratulations - you've just created your first board. You can see boards, that you have access to, on this page:
|
||||
Congratulations - you've just created another board. You can see the boards you have access to on this page:
|
||||
|
||||

|
||||
<figure>
|
||||
<img
|
||||
src={boardsHomeMobileMenu}
|
||||
alt="Boards management page showing a Home badge and separate desktop and mobile home-board menu entries"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
<figcaption>The Boards page selects the board opened from Home separately for desktop and mobile.</figcaption>
|
||||
</figure>
|
||||
|
||||
The board marked **Home** is the board opened from the desktop Home route. **Set as your mobile board** selects which board
|
||||
opens on phones. This is separate from the **Mobile** layout: the home-board setting chooses a board, while **Mobile** and
|
||||
**Base** are responsive layouts inside that board. A phone can therefore open a different home board and still use that
|
||||
board's Mobile layout.
|
||||
|
||||
New boards start with **Mobile** and **Base** responsive layouts automatically, so you can edit the board for phones and
|
||||
desktops without creating extra layouts first. Mobile starts at `0px` with 3 columns and Base at `768px` with 10 columns.
|
||||
Each layout keeps its own tile positions and sizes.
|
||||
|
||||
## Add your first app to Homarr
|
||||
|
||||
@@ -98,37 +120,63 @@ After you created the app, you'll see in on the apps page:
|
||||
Let's add your freshly created app, from the previous step, to the board.
|
||||
To do this, edit your previously created board by clicking on the big `Open board` button:
|
||||
|
||||

|
||||
<figure>
|
||||
<img src={boardDesktopCurrent} alt="Current Homarr board opened from the Boards page" style={{ maxWidth: "100%" }} />
|
||||
<figcaption>Open a board to view its dashboard, then enter edit mode to arrange its content.</figcaption>
|
||||
</figure>
|
||||
|
||||
Here you have the following options in the header:
|
||||
|
||||

|
||||
|
||||
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
|
||||
- **Edit mode**: Clicking this button will toggle the edit mode. In this mode you can edit the contents of your board.
|
||||
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
|
||||
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
|
||||
- **Board switcher**: Use this button to open another board that you can access.
|
||||
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
|
||||
|
||||
When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
<img src={manageBoardHeaderEditMode} alt={"Screenshot of buttons in the edit mode"} width={200} />
|
||||
<figure>
|
||||
<img
|
||||
src={boardEditModeCurrent}
|
||||
alt="Current Homarr board editor with tile controls visible"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
<figcaption>The current editor keeps the board visible while its editing controls are available.</figcaption>
|
||||
</figure>
|
||||
|
||||
- **Add item**: Click this button to add new elements to your page.
|
||||
- **Exit edit mode**: Click this button to save your changes and exit edit mode.
|
||||
- **Add board content**: Click this button to create a new item, app, integration, or Container.
|
||||
- **Save board**: Click this button to save your changes and leave edit mode. If you leave with unsaved changes, Homarr asks whether to discard them.
|
||||
|
||||
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
|
||||
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
|
||||
stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
|
||||
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
|
||||
|
||||
To continue, click the option "New Item":
|
||||
To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
|
||||
canvas; it is not inserted at a clicked empty cell or directly into a rail or Container:
|
||||
|
||||
<img src={manageBoardHeaderAddItem} alt={"Screenshot of the add item menu"} width={200} />
|
||||
<figure>
|
||||
<img
|
||||
src={addContentMenuCurrent}
|
||||
alt="Current add board content menu with item, app, integration, and Container actions"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
<figcaption>The add-content menu provides the four supported ways to place new content on the board.</figcaption>
|
||||
</figure>
|
||||
|
||||
Next, you can choose what item you'd like to add.
|
||||
We will discuss the other options in the following sections - for now choose "App" to continue.
|
||||
|
||||
<img src={manageBoardHeaderChooseItem} alt={"Screenshot of choosing the app item in the modal"} width={300} />
|
||||
<figure>
|
||||
<img
|
||||
src={chooseItemCurrent}
|
||||
alt="Current item picker showing the App card and other item types"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
<figcaption>Choose the App item from the current picker to add an app tile.</figcaption>
|
||||
</figure>
|
||||
|
||||
As soon as you chose the app, the modal will close and you'll get the following item on your board:
|
||||
|
||||
@@ -137,30 +185,52 @@ As soon as you chose the app, the modal will close and you'll get the following
|
||||
This is expected, because you haven't told Homarr what app it should show in this item.
|
||||
To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu.
|
||||
|
||||

|
||||
<figure>
|
||||
<img
|
||||
src={moveResizeMenuCurrent}
|
||||
alt="Current board tile menu showing Edit item and Move or resize item"
|
||||
style={{ maxWidth: "450px" }}
|
||||
/>
|
||||
<figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption>
|
||||
</figure>
|
||||
|
||||
In the modal you can edit the app that you want to display:
|
||||
|
||||

|
||||
<figure>
|
||||
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
|
||||
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
|
||||
</figure>
|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
Use the resize handle at the bottom right to resize the tile.
|
||||
Press and hold the handle, move the pointer, and release it when the tile has the size you want.
|
||||
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
|
||||
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
|
||||
|
||||

|
||||
|
||||
To move a tile in edit mode, press and hold its content, move it to the desired position, and release it.
|
||||
To move a tile in edit mode, press and hold its inert content area, move it to the desired position, and release it.
|
||||
Widget content is disabled while editing so its controls do not intercept the drag; use the tile's settings button for edit actions.
|
||||
The tile snaps to the grid. Equally-sized tiles swap when one is dropped into the other's slot, while other neighboring tiles move out of the way automatically.
|
||||
|
||||

|
||||
The tile snaps to the grid. A single compatible collision may swap the two items when both resulting placements fit;
|
||||
otherwise the collision chain reflows neighboring tiles automatically.
|
||||
|
||||
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.
|
||||
It stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small.
|
||||
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 does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
|
||||
|
||||
On a narrow screen, Homarr uses the **Mobile layout** for the selected board and stacks the board content vertically while
|
||||
preserving each widget's fixed logical proportions:
|
||||
|
||||
<figure>
|
||||
<img
|
||||
src={currentBoardMobileView}
|
||||
alt="Homarr's Mobile layout in a 390-pixel-wide viewport, with tiles scaled to fit and stacked vertically"
|
||||
style={{ maxWidth: "390px" }}
|
||||
/>
|
||||
<figcaption>
|
||||
At phone width, the selected board uses its Mobile layout and fits the complete canvas to the viewport.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
:::note
|
||||
|
||||
Viewing a board initially uses the lightweight, non-interactive layout.
|
||||
@@ -176,16 +246,17 @@ This helps you to explore the capabilities of Homarr before you build your board
|
||||
|
||||
:::
|
||||
|
||||
Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize.
|
||||
Press Escape to stop.
|
||||
For precise numeric controls or touch input, choose **Move / resize item** from the tile's edit menu.
|
||||
The dialog can also move the tile to another section or fixed sidebar:
|
||||
Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize
|
||||
within its current canvas, rail, or Container. Press Escape to stop keyboard editing. Touch dragging starts after a short
|
||||
hold of about `200 ms`; resize handles accept direct touch input. During a pointer or touch move or resize, press Escape
|
||||
to cancel and restore the previous layout. For cross-grid moves or precise numeric controls, choose **Move / resize item**
|
||||
from the tile's edit menu.
|
||||
Keyboard commands at a grid boundary or minimum tile size are ignored and announced; they do not move or shrink the tile.
|
||||
|
||||

|
||||
<img src={currentKeyboardTileEditing} alt="A board tile focused for keyboard editing" style={{ maxWidth: "100%" }} />
|
||||
|
||||
Be sure to save your changes by exiting edit mode!
|
||||
|
||||

|
||||
Open **Move / resize item** from the tile menu to move the tile to another section or rail and enter precise numeric
|
||||
coordinates and dimensions. After making changes, click **Save board** to persist them and leave edit mode.
|
||||
|
||||
## Adding your first integration
|
||||
|
||||
@@ -220,7 +291,14 @@ Let's add your integration, that you have configured in the section above, as a
|
||||
First, navigate back to your board that you want to add it to.
|
||||
Next, enter edit mode again and add another item to your board:
|
||||
|
||||

|
||||
<figure>
|
||||
<img
|
||||
src={addContentMenuCurrent}
|
||||
alt="Current add board content menu with item, app, integration, and Container actions"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
<figcaption>Use the same add-content menu to place an integration widget on the board.</figcaption>
|
||||
</figure>
|
||||
|
||||
Instead of choosing an app, let's choose `System Health Monitoring` this time.
|
||||
Click on "Add to board":
|
||||
@@ -247,9 +325,7 @@ Editing a widget will trigger a data update and data will soon appear:
|
||||
|
||||

|
||||
|
||||
As with every change, don't forget to save your changes using the exit edit mode button:
|
||||
|
||||

|
||||
As with every change, click **Save board** to persist your board changes and leave edit mode.
|
||||
|
||||
## Organizing items with Containers
|
||||
|
||||
@@ -262,7 +338,7 @@ Each Container can configure:
|
||||
- Show or hide **Open all in new tabs**.
|
||||
- Change its border color and custom CSS classes.
|
||||
|
||||
Open the add-content menu and choose **New Container**.
|
||||
Open the add-content menu and choose **New container**.
|
||||
In edit mode, move and resize it like any other item, then drag items into or out of it.
|
||||
Nested content stays within the Container's bounds.
|
||||
While resizing, the solid outline follows the pointer continuously and the dashed grid preview shows the size that will be saved on release.
|
||||
|
||||
|
After Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -24,12 +24,11 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers";
|
||||
]}
|
||||
/>
|
||||
|
||||
## Enabling the Docker integration
|
||||
## Connecting Docker to Homarr
|
||||
|
||||
This integration must first be enabled.
|
||||
If you directly run on the host system and you have docker installed, it will be enabled by default.
|
||||
Otherwise, you'll need to mount the [docker socket](https://docs.docker.com/engine/security/protect-access/) to the Homarr container,
|
||||
so Homarr can interact with the Docker service.
|
||||
Docker connections come directly from Homarr's environment variables. They are automatically available in the Docker management table and Docker widget; you do not create a Docker integration under **Management → Integrations**.
|
||||
|
||||
If Homarr runs directly on a host with Docker installed, it uses `/var/run/docker.sock` by default. When Homarr runs in a container, mount the [Docker socket](https://docs.docker.com/engine/security/protect-access/) into it so Homarr can interact with Docker.
|
||||
|
||||
In Docker Compose, you can simply add this line:
|
||||
|
||||
@@ -122,7 +121,23 @@ services:
|
||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||
```
|
||||
|
||||
For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
||||
For remote socket proxies, list the hostnames and matching ports in the same order:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DOCKER_HOSTNAMES: docker-proxy-home,docker-proxy-lab
|
||||
DOCKER_PORTS: 2375,2375
|
||||
```
|
||||
|
||||
Homarr automatically discovers each comma-separated entry as a separate Docker environment. All environments are selected by default; the Docker widget lets you select one or more without any additional setup.
|
||||
|
||||
:::warning[Plaintext TCP]
|
||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy on a private Docker network, or TLS.
|
||||
:::
|
||||
|
||||
### Advanced endpoint configuration
|
||||
|
||||
Most users should use the comma-separated variables above. Use `DOCKER_ENDPOINTS` only when you need custom names, TLS, or restricted capabilities. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -163,16 +178,12 @@ Supported transports are:
|
||||
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
|
||||
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
|
||||
|
||||
:::warning[Plaintext TCP]
|
||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`.
|
||||
:::
|
||||
|
||||
When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables.
|
||||
The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. When `DOCKER_ENDPOINTS` is set, it takes precedence over `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS`.
|
||||
|
||||
### Managing your Docker containers within Homarr
|
||||
|
||||
Homarr allows you to interact with Docker containers running on your system.
|
||||
The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
||||
The collapsed **Assisted Docker setup** helper optionally compares visible containers with your existing Homarr apps and integrations. Open it only when you want setup suggestions; it never blocks normal container management. Every service stays distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
||||
|
||||
Homarr can show a service as:
|
||||
|
||||
@@ -181,15 +192,15 @@ Homarr can show a service as:
|
||||
- **Already represented** or **Linked** when an app or integration already matches
|
||||
- **Address changed** when a service appears to match an integration but its reachable address has changed
|
||||
|
||||
Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing.
|
||||
Set the usual server address once, then choose whether services normally use different ports, subdomains, or paths. Homarr uses that pattern to prefill every detected service. Each suggestion has one editable URL field with the generated and Docker-reported addresses available as autocomplete options, so you can select a suggestion or type any address directly.
|
||||
|
||||
:::info[Review before changing]
|
||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
|
||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically. **Set up integration** opens the standard prefilled integration form, while **Create app** opens the existing Docker app form, so you can review and confirm the final setup.
|
||||
:::
|
||||
|
||||
Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
||||
Suggestions appear as a compact task list. Expand an item only when you want to edit its URL or inspect relevant setup statuses. Use the filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same helper. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
||||
|
||||
The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy.
|
||||
Compact status icons summarize the setup information Homarr can act on. Unknown and non-applicable runtime layers are omitted to keep the helper focused; use the integration form's connection test or the relevant board action for live diagnostics.
|
||||
|
||||
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
|
||||
|
||||
@@ -216,6 +227,10 @@ This opens a dedicated page with an xterm.js terminal that streams the container
|
||||
The Docker management table supports optional **CPU usage** and **Memory usage** columns.
|
||||
These columns are hidden by default and can be toggled on from the table column visibility settings.
|
||||
|
||||
#### Choosing Docker environments in the widget
|
||||
|
||||
The Docker widget can show one or more configured Docker environments. Leave **Docker environments** empty to include every endpoint, which is also the default for existing widgets. Selecting one or more entries limits container and statistics requests to those endpoints.
|
||||
|
||||
#### Add apps from containers
|
||||
|
||||
Homarr also lets you add apps from containers. You can simply select all containers you want to add an app for and then click the `Add to Homarr` button.
|
||||
|
||||
@@ -110,6 +110,10 @@ Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.resta
|
||||
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
|
||||
Docker or Podman endpoints from targeting the wrong service.
|
||||
|
||||
`docker.getContainers` also accepts an optional `endpointIds` array. Omit it or pass an empty array to query every configured
|
||||
Docker environment, or pass one or more IDs returned by `docker.getEndpoints` to query only those Dockerode clients. The
|
||||
Docker widget uses the same behavior for its environment selector.
|
||||
|
||||
## Partial integration failures
|
||||
|
||||
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
|
||||
@@ -117,6 +121,10 @@ an optional `error` on a failed integration; media-request responses expose a `f
|
||||
requests endpoint returns its items in `requests`. When every selected integration fails, the query returns an error instead
|
||||
of a partial response.
|
||||
|
||||
Other widgets use the same settlement pattern with endpoint-specific shapes: a failed integration may appear as a named
|
||||
fallback row, in a failure array, or be omitted while healthy results remain. Widget pages should identify the shape they
|
||||
render rather than assuming every query exposes an `error` field.
|
||||
|
||||
### Saving dashboard layouts
|
||||
|
||||
The dashboard editor saves item positions and sections as one board update.
|
||||
|
||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 256 KiB |
@@ -11,15 +11,17 @@ tags:
|
||||
|
||||
import iconColorImage from "./img/icon-color.png";
|
||||
import itemRadiusImage from "./img/item-radius.png";
|
||||
import layoutLargeImage from "./img/layout-large.png";
|
||||
import layoutMediumImage from "./img/layout-medium.png";
|
||||
import layoutSmallImage from "./img/layout-small.png";
|
||||
import layoutSettingsImage from "./img/layout-settings.png";
|
||||
import layoutSettingsCustomRailsImage from "./img/layout-settings-custom-rails.png";
|
||||
import boardViewModeImage from "./img/board-view-mode.png";
|
||||
import boardEditModeImage from "./img/board-edit-mode.png";
|
||||
import keyboardTileEditingImage from "./img/keyboard-tile-editing.png";
|
||||
|
||||
Each dashboard is called a "board".
|
||||
You can create as many boards as you want.
|
||||
Boards can be restricted to user groups.
|
||||
A user may also have a "home board" which they will see at the root path (e.g. my-homarr.com or my-homarr.com/board).
|
||||
Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) or via the management pages.
|
||||
A user may also have a "home board" which they will see at the root path (e.g. my-homarr.com or my-homarr.com/boards).
|
||||
Other boards can be viewed by direct links (e.g. my-homarr.com/boards/smart-home) or via the management pages.
|
||||
The management page includes a compact structural preview of each board's Base layout so you can recognize it before
|
||||
opening it. Containers include their visible widgets and app icons instead of appearing empty.
|
||||
|
||||
@@ -94,9 +96,23 @@ Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
In this section you can configure the different responsive layouts. Homarr selects the layout with the highest breakpoint that is not wider than the current screen, then scales its complete fixed-size canvas uniformly.
|
||||
|
||||

|
||||
<img
|
||||
src={layoutSettingsImage}
|
||||
alt="Board settings showing protected Mobile and Base responsive layouts with rail controls"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
Every board has two protected layouts:
|
||||
The layout cards show the representative geometry used for planning. This current example also shows a custom layout
|
||||
with both fixed rails enabled; the rails reserve columns and the remaining canvas reflows automatically.
|
||||
|
||||
<img
|
||||
src={layoutSettingsCustomRailsImage}
|
||||
alt="Board layout settings with a custom layout and both fixed rails enabled"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
Every board has two protected layouts. Creating a board adds **Mobile** at `0px` with three columns and **Base** at
|
||||
`768px` with the column count you choose. Both layouts can be edited, but neither can be removed.
|
||||
|
||||
Every `1 × 1` tile has a `200 × 200` logical-pixel content area.
|
||||
The `24` logical-pixel gap between tiles is separate from that size.
|
||||
@@ -109,9 +125,18 @@ 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 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.
|
||||
|
||||
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.
|
||||
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 start from Base's current column count and rail widths; Homarr chooses a
|
||||
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails.
|
||||
|
||||
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
|
||||
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
|
||||
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
|
||||
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
|
||||
|
||||
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
|
||||
|
||||
@@ -130,13 +155,14 @@ 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.
|
||||
|
||||
Rail columns are reserved from the layout's total column count.
|
||||
For example, a 12-column layout with a two-column left rail leaves ten columns for the canvas.
|
||||
The combined rail widths cannot consume the final main-canvas column.
|
||||
Enabling or widening a rail reflows canvas items into the remaining columns.
|
||||
Disabling a rail moves its contents back into the next available canvas positions.
|
||||
|
||||
@@ -145,19 +171,38 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
The minimum screen width where the layout is used. Mobile is fixed at `0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
|
||||
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
|
||||
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
|
||||
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
|
||||
fallback to select a layout.
|
||||
|
||||
#### View and edit modes
|
||||
|
||||
View mode renders a static board without executing the drag-and-drop editor.
|
||||
After the board is interactive, Homarr prepares the editor in the background for users who can modify it.
|
||||
Entering edit mode keeps the static board visible until that preparation finishes, then enables collision-aware movement and resizing without changing the board geometry.
|
||||
Widget content is inert in edit mode, so dragging anywhere on the content moves the tile instead of activating the widget; its settings button remains visible in the compact header above the tile.
|
||||
Dropping a tile into another tile's exact slot swaps their positions when both resulting placements fit without overlapping other content.
|
||||
For other collisions, or when a tile grows into occupied space, nearby tiles are compacted out of the way.
|
||||
Entering edit mode keeps the static board visible until that preparation finishes, then enables collision-aware movement and resizing.
|
||||
Collapsed Containers are expanded while editing so their contents and controls are reachable.
|
||||
|
||||
<img
|
||||
src={boardViewModeImage}
|
||||
alt="A Homarr board in view mode with the dashboard canvas and static widgets visible"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
Widget content is inert in edit mode, so dragging from the inert widget area moves the tile instead of activating the widget;
|
||||
settings and other controls remain usable in the compact header above the tile.
|
||||
A single compatible collision can swap two items when both resulting placements fit, including different-sized or lateral moves.
|
||||
For other collisions, or when a tile grows into occupied space, nearby tiles are compacted out of the way and collision chains reflow.
|
||||
The visible corner controls provide a 44-pixel resize target, and edge controls appear on hover for horizontal or vertical resizing.
|
||||
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
|
||||
Exit edit mode to save the resulting positions.
|
||||
Use the pencil button in the board header to enter edit mode; click it again to save. If you leave the page with unsaved
|
||||
changes, Homarr asks whether to discard them. If the editor cannot load, the board remains in view mode.
|
||||
|
||||
<img
|
||||
src={boardEditModeImage}
|
||||
alt="Board in edit mode with the board header controls and editable dashboard tiles visible"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
||||
Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
@@ -168,25 +213,34 @@ The widget editor also lists its selected integrations. Users with full access c
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
saved integration changes apply everywhere that connection is used.
|
||||
|
||||
Pointer users can drag anywhere on a widget tile. Containers use the grab button at their top-left corner.
|
||||
Resize tiles from any edge or corner handle.
|
||||
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
|
||||
Pointer users can drag from the inert widget area; interactive controls are excluded. Containers use the grab button at their top-left corner.
|
||||
Widgets can be resized from any edge or corner handle. Activate a Container's chrome with its top-left grip before using
|
||||
its resize handles.
|
||||
On a touch screen, press and hold a tile for about `200 ms` before moving it; a quick tap does not start a drag. Resize
|
||||
handles support direct touch input.
|
||||
Keyboard users can focus a tile, press Enter or Space to start editing, use the arrow keys to move it, and use Shift plus the arrow keys to resize it.
|
||||
Press Enter, Space, or Escape to stop keyboard editing.
|
||||
Keyboard moves and resizes apply immediately; Escape stops keyboard editing without undoing those changes.
|
||||
Keyboard moves and resizes apply immediately within the current canvas, rail, or Container; they cannot move an item
|
||||
between grids. Use pointer drag or **Move / resize item** for that.
|
||||
Escape stops keyboard editing without undoing those changes.
|
||||
During a pointer or touch interaction, Escape cancels the move or resize and restores the layout from before that interaction.
|
||||
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
|
||||
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
||||
|
||||
<img
|
||||
src={keyboardTileEditingImage}
|
||||
alt="A focused board tile in keyboard editing mode"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
#### Preview
|
||||
|
||||
Layout Settings shows a lightweight structural preview for every layout, including saved spans, Containers, widget labels, and app icons. The canvas width represents the screen range where that layout is active.
|
||||
Layout Settings shows a lightweight structural preview for every layout, including saved spans, Containers, widget labels, and app icons.
|
||||
It uses a representative viewport width to project the saved geometry; it does not execute widget queries or represent every
|
||||
pixel of the screen range where the layout is active.
|
||||
|
||||
Choose **Edit layout** to open the real board editor at that representative width on desktop. Save returns to Layout Settings. Cancel discards the local changes. On phones and tablets, the normal board editor continues to use the layout selected by the device width.
|
||||
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
|
||||
### Containers and rails
|
||||
|
||||
Containers can hold apps, widgets, and nested Containers.
|
||||
@@ -258,9 +312,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.
|
||||
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
|
||||
Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an
|
||||
editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
|
||||
or Danger zone actions.
|
||||
|
||||
[See this guide on how to work with custom CSS](/docs/advanced/styling/).
|
||||
|
||||
@@ -277,12 +332,12 @@ Widgets expose their routine controls without requiring edit mode:
|
||||
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
|
||||
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
|
||||
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
|
||||
- Right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run
|
||||
supported actions. Available controls follow your board and integration permissions.
|
||||
- If you can modify the board, right-click or touch and hold an empty grid cell to add an item there. Focus a board
|
||||
section and press `Shift` + `F10` for the keyboard equivalent.
|
||||
- When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect
|
||||
its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do
|
||||
not use this widget menu, and available controls follow your board and integration permissions.
|
||||
|
||||
Widget and empty-space context menus follow the **Enable right click on widgets** preference in your user settings.
|
||||
The widget context menu follows the **Enable right click on widgets** preference in your user settings. On touch devices,
|
||||
use the widget menu where your browser exposes it.
|
||||
|
||||
### Access control
|
||||
|
||||
@@ -341,7 +396,8 @@ Duplicating a board will not copy the users and groups that have access to the b
|
||||
nor the integrations the user does not have access to.
|
||||
:::
|
||||
|
||||
Duplicating a board will create a new board with the same settings and layout as the original board.
|
||||
Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board.
|
||||
Duplicating a board that contains Custom Widgets also requires an administrator.
|
||||
You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board".
|
||||
|
||||

|
||||
@@ -399,5 +455,13 @@ For the global home board you can only select boards that are public.
|
||||
|
||||
### Mobile home board
|
||||
|
||||
Same as the home board, you can also set a mobile home board.
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
The mobile home board controls which board opens for devices that Homarr detects as mobile from the User-Agent header.
|
||||
This is separate from a board's **Mobile layout**, which controls the layout selected inside that board.
|
||||
You can set a mobile home board for yourself in your user preferences, for a user or group through management, or for the
|
||||
whole server with **Global mobile board** under `Management` > `Settings` > `Boards`.
|
||||
|
||||
For signed-in users, Homarr checks the user's mobile home board, then the user's desktop home board, then the mobile and
|
||||
desktop defaults for the lowest-position matching group, followed by the mobile and desktop defaults for the `Everyone`
|
||||
group, and finally the server-wide mobile and desktop defaults. Anonymous visitors use the server-wide mobile default and
|
||||
then the server-wide desktop default. Server-wide fallback boards must be public. See the
|
||||
[global board defaults](/docs/management/settings#boards) for the complete precedence and the required public-board rule.
|
||||
@@ -67,17 +67,23 @@ These interfaces do not return an archive URL or archive hash.
|
||||
|
||||
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.
|
||||
components and `customWidget_getExample` when one catalog example matches the interaction pattern. Fetch at most
|
||||
eight component documents before the first validation (or four after a complete example already matches). 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.
|
||||
7. Run `customWidget_previewQuery` for every query returned by the preview and inspect its actual status and data shape. Assistant conversations retain a bounded response preview so large APIs cannot exhaust the model context. Use
|
||||
`{}` for load queries; manual queries require exactly the returned primitive `parameterNames`.
|
||||
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.
|
||||
10. Inspect the redacted journal and revise the local candidate until validation, data, and rendering all work. Keep the
|
||||
exact tested preview candidate locally. Any definition, template, or source change requires a new validation and preview;
|
||||
earlier query evidence belongs to the previous preview revision.
|
||||
11. Create the widget from that final tested preview with `customWidget_createFromPreview`. The preview session already carries
|
||||
the source URLs and encrypted credentials supplied during preview; the create call accepts only its session ID plus an
|
||||
optional target board and persists that tested preview.
|
||||
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.
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
# Create a Custom Widget
|
||||
|
||||
import { CanonicalCustomWidgetExample } from "@site/src/components/custom-widget-code";
|
||||
import workbenchImage from "./img/workbench.png";
|
||||
import workbenchMobileImage from "./img/workbench-mobile.png";
|
||||
import workbenchValidationImage from "./img/workbench-validation.png";
|
||||
|
||||
The workbench is visual-first: configure the default API connection, add request cards, add option rows, then write JSX. Additional sources and raw manifest JSON remain under Advanced. Code and preview sit side by side on desktop and use tabs on mobile.
|
||||
The workbench exposes **General**, **API sources**, **Requests**, **Options**, **JSX**, and **Preview** sections. Advanced JSON is
|
||||
available in the manifest and request/option editors. Code and preview sit side by side on desktop; on smaller screens the
|
||||
**Configure**/**Preview** switch shows one pane at a time and the section navigation scrolls horizontally. Preview sizes display
|
||||
as **S/M/L** (Compact/Standard/Wide), and fixtures are **Live**, **Loading**, **Empty**, or **Error**.
|
||||
For multiline JSX, prefer `templateLines` in agent or MCP input and keep the final tested preview as the source of truth before saving.
|
||||
|
||||
<img
|
||||
src={workbenchImage}
|
||||
alt="Custom Widget workbench with configuration sections and a live preview"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
<img
|
||||
src={workbenchMobileImage}
|
||||
alt="Mobile Custom Widget workbench with Configure and Preview switch and horizontally scrollable section navigation"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
The workbench keeps the configuration and preview together so you can inspect validation diagnostics, the redacted request
|
||||
journal, fixture data, and preview size before saving. Invalid fields stay marked in the form while the preview explains why it
|
||||
needs attention.
|
||||
|
||||
<img
|
||||
src={workbenchValidationImage}
|
||||
alt="Custom Widget workbench showing a Base URL validation error with Test, preview, and Create actions disabled"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
## Manual workflow
|
||||
|
||||
@@ -21,7 +50,9 @@ The workbench is visual-first: configure the default API connection, add request
|
||||
|
||||
<CanonicalCustomWidgetExample id="widget-options" label="Widget options" example="options" height="360px" />
|
||||
|
||||
Every option has a default. Supported controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, URL, duration, time zone, and JSON. Dynamic choices use `choicesFrom` with a query ID and value/label paths.
|
||||
Every option has a default. Supported controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, URL, duration, time zone, and JSON. Dynamic choices use `choicesFrom: { request, itemsPath?, valuePath, labelPath }`; the request must be a load query without invocation parameters, and you cannot provide both static `choices` and `choicesFrom`.
|
||||
|
||||
Preview actions are simulated by default and recorded in the journal without contacting the service. Enable **Run actions against the configured API** only when you intend a real mutation; live actions still require configured confirmation.
|
||||
|
||||
## ChatGPT or Claude
|
||||
|
||||
@@ -31,7 +62,8 @@ The model must return one fenced `json` block containing the complete widget, in
|
||||
field. Copy that single block and select **Paste**. Homarr fills the visual builders and the JSX editor
|
||||
without any placeholder replacement or second paste.
|
||||
|
||||
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
|
||||
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The copied prompt does not include the
|
||||
latest preview response; use **Preview** > **Data** to inspect it and mention important response fields in your request. The prompt deliberately
|
||||
recommends a focused Mantine component set, visual hierarchy, responsive layout, theme compatibility, and loading,
|
||||
empty, failure, and success states.
|
||||
|
||||
|
||||
@@ -7,7 +7,21 @@ Templates are JSX expressions interpreted inside a widget-local safety and rende
|
||||
- `options.name` — saved widget configuration
|
||||
- `inputs.name` — temporary values from declarative `bind`
|
||||
|
||||
The v2 manifest requires a `default` source and supports up to eight sources, 64 requests, and 64 options. Source IDs are
|
||||
case-insensitively unique. Each request allows up to 32 static headers and 32 invalidation targets; a widget can contain
|
||||
up to 24 secrets, each up to 8,192 characters, and templates are limited to 50,000 characters. Validation reports the
|
||||
exact field when a limit is exceeded.
|
||||
|
||||
Authoring input accepts either `template` or `templateLines`, not both. `templateLines` accepts 1–2,000 lines, each up to
|
||||
10,000 characters; after joining lines with newlines, the complete template must still fit the 50,000-character limit.
|
||||
|
||||
The safe interpreter also bounds JSX evaluation: AST depth 64, 25,000 operations, 4,000 collection items, 10,000 rendered
|
||||
nodes, and 200,000 characters in one string or rendered value. API responses are bounded separately before they reach the
|
||||
template.
|
||||
|
||||
Use normal Mantine layouts, typography, feedback, dates, and charts. Official compound names such as `Card.Section`, `Tabs.List`, and `Radio.Group` are supported. Ordinary safe props pass through; a dangerous capability produces an explicit error.
|
||||
Unsafe URLs, callback props, and unapproved style properties are stripped. Response data is deep-sanitized and budgeted
|
||||
before it reaches `data`, so treat it as untrusted input even when the source is private.
|
||||
|
||||
## Temporary inputs
|
||||
|
||||
@@ -15,7 +29,7 @@ Use normal Mantine layouts, typography, feedback, dates, and charts. Official co
|
||||
<Stack>
|
||||
<TextInput bind="search" defaultValue="" label="Search" />
|
||||
<SubFetch requestId="search" params={{ query: inputs.search }}>
|
||||
{(results) => (
|
||||
{(results, meta) => (
|
||||
<Stack>
|
||||
{(results ?? []).map((item) => (
|
||||
<Text key={item.id}>{item.name}</Text>
|
||||
@@ -32,14 +46,23 @@ Bound values live only in React memory while the widget is mounted. They are not
|
||||
|
||||
Use expressions, conditionals, optional chaining, arrays, objects, template strings, safe standard helpers, and expression callbacks for `map`, `filter`, `sort`, and trusted runtime slots. Callback blocks, authored `const`, IIFEs, authored recursion, arbitrary functions, imports, hooks, refs, raw events, browser requests, `eval`, and bigint are blocked.
|
||||
|
||||
Bounded regex literals are supported for string matching, splitting, search, and replacement. Patterns with lookbehind, backreferences, nested quantifiers, excessive length, or unsupported flags are rejected.
|
||||
Bounded regex literals are supported for string matching, splitting, search, and replacement. Regex literals are limited to
|
||||
128 characters and flags `gimsu`; lookarounds, backreferences, repeated alternatives/groups, unsupported flags, and more
|
||||
than one variable quantifier are rejected.
|
||||
|
||||
## Runtime components
|
||||
|
||||
- `SubFetch` runs a manual query with its `params`. Set `triggerContent` to a card or image to make that content the accessible click and keyboard trigger instead of showing the default load button.
|
||||
- `ActionButton` and `ToggleSwitch` run actions.
|
||||
- `RefreshButton` renders a compact, accessible icon control that refreshes load queries for authenticated viewers. It
|
||||
is disabled for anonymous public-board viewers because refreshing invalidates shared server cache state.
|
||||
- `SubFetch` runs a named query. Automatic queries run when the widget loads; set `trigger="manual"` to defer one and pass
|
||||
primitive `params` for its invocation references. With `triggerContent`, any content node (commonly a card or image) becomes
|
||||
the accessible click and keyboard trigger instead of showing the default load button.
|
||||
- `SubFetch` with `trigger="manual"` keeps its result local to that instance; it does not populate the shared
|
||||
`data.requestId` or `status.requestId` roots. The child callback receives `(result, meta)`, while loading, errors, and
|
||||
retry are handled by `SubFetch`. Provide both `triggerContent` and `triggerAriaLabel` when custom content launches it.
|
||||
- `ActionButton` and `ToggleSwitch` run actions only when the widget is mounted outside edit mode. A toggle needs both
|
||||
enabled and disabled request parameters and rolls back its visual state if the action fails.
|
||||
- `RefreshButton` renders a compact, accessible icon control that invalidates the client's load-query cache so active load
|
||||
queries can refetch; it does not execute a request itself. It is disabled when the widget has no active item or preview
|
||||
session, or while the board is in edit mode.
|
||||
- `Icon`/`TablerIcon` resolves installed safe icons.
|
||||
|
||||
Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile.
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -42,9 +42,14 @@ 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.
|
||||
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 generated prompt includes the
|
||||
widget schema and current non-secret configuration, but not the latest preview response. It 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.
|
||||
Use the preview's **Data** tab to inspect the endpoint's real response shape and mention important fields in your request; the
|
||||
copied prompt does not automatically include the latest preview response. 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"
|
||||
@@ -62,9 +67,9 @@ preview, publish, install, configure, or write secrets, including through HTTP a
|
||||
only lists Custom Widget MCP tools, prompts, and resources when its owner is an administrator.
|
||||
|
||||
Adding or configuring a Custom Widget placement requires both administrator access and permission to modify that
|
||||
board. A non-administrator with board-modify access can move, resize, change presentation settings, or remove an
|
||||
existing placement, but cannot add one, change its definition, refresh interval, or Custom Widget options. Duplicating
|
||||
a board that contains Custom Widgets also requires an administrator.
|
||||
board. Duplicating a board that contains Custom Widgets also requires an administrator. A non-administrator with
|
||||
board-modify access can move, resize, change presentation settings, or remove an existing placement, but cannot add one,
|
||||
change its definition, refresh interval, or Custom Widget options.
|
||||
|
||||
Once an administrator has placed a widget, ordinary board permissions apply to rendering and requests. Authorized
|
||||
non-administrators can use it on private boards, and queries can render for anonymous viewers on public boards.
|
||||
@@ -99,14 +104,12 @@ logs, or exports.
|
||||
For administrators adding widgets while editing a board, the Custom Widgets section includes **Import from
|
||||
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. Selecting **Install widget**
|
||||
opens a two-step modal flow: first review the Workshop entry and its complete source in the read-only editor, then
|
||||
confirm API sources and credentials. The steps keep the same dimensions and the previous step remains available when
|
||||
you go back. After import, the new definition appears in the picker. Its tile
|
||||
waits for the board to be saved before server requests run and loads automatically after a successful save; no page
|
||||
reload is required.
|
||||
confirm API sources and credentials. After import, the new definition appears in the picker. Its tile waits for the board
|
||||
to be saved before server requests run.
|
||||
|
||||
## Import and Workshop
|
||||
|
||||
The install flow always links to the corresponding public Workshop entry using `WORKSHOP_WEB_URL`. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
|
||||
The install flow always links to the corresponding public Workshop entry under the configured Workshop origin. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
|
||||
|
||||
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
|
||||
|
||||
@@ -131,6 +134,7 @@ replace the v1 backup.
|
||||
|
||||
## Bundled examples
|
||||
|
||||
Homarr seeds five disabled examples without credentials and never overwrites an edited seed. They use the same public contract. The Tautulli example is the highest-scoring widget preserved from Homarr's AI authoring evaluation:
|
||||
Homarr seeds five disabled examples without credentials and never overwrites an edited seed. They use the same public
|
||||
contract. Tautulli Activity is one of the bundled examples preserved from Homarr's AI authoring evaluation:
|
||||
|
||||
<BundledCustomWidgetGallery />
|
||||
@@ -1,6 +1,11 @@
|
||||
# Configure requests and security
|
||||
|
||||
Most widgets use a single source named `default`. Extra keyed sources are available under Advanced for widgets that combine APIs. Authentication is `none`, `bearer`, `basic`, an API-key header, or an API-key query parameter. Credentials belong to the widget but remain outside its manifest.
|
||||
Most widgets use a single source named `default`; the manifest must contain it. Extra keyed sources are available for widgets
|
||||
that combine APIs. Source IDs are case-insensitively unique. Base URLs must use HTTP(S), contain no embedded credentials,
|
||||
query string, or fragment, and sources can be `public`, `private`, or `loopback`. Authentication is `none`, `bearer`,
|
||||
`basic`, an API-key header, or an API-key query parameter. Credentials belong to the widget but remain outside its manifest.
|
||||
Source origins are fixed by the definition and source-setup flow; widget options and invocation parameters cannot redirect a
|
||||
request to a different origin.
|
||||
|
||||
## Named requests
|
||||
|
||||
@@ -35,19 +40,28 @@ Bind values directly where they are used:
|
||||
|
||||
`{option:name}` and `$option` read saved widget options. `{param:name}` and `$param` read invocation-time `params`. Load queries cannot use invocation parameters. Homarr infers the exact parameter names and primitive values from these references, rejecting missing or extra invocation params at the request boundary.
|
||||
|
||||
Paths and query-string values must resolve to text, numbers, or booleans. JSON bodies can also use structured `json` and `multiSelect` option values, for example `{ "entity_ids": { "$option": "entities" } }`.
|
||||
Path placeholders and query values must resolve to text, numbers, or booleans. `$option` and `$param` references may appear
|
||||
recursively in JSON bodies; invocation parameters remain primitive, and load queries cannot use `$param`. JSON bodies can
|
||||
also use structured `json` and `multiSelect` option values, for example `{ "entity_ids": { "$option": "entities" } }`.
|
||||
|
||||
## Protection
|
||||
|
||||
Every request uses Homarr's existing server executor: same-origin source enforcement, network scope, DNS and redirect validation, SSRF protection, board permissions, rate and request limits, body and response limits, timeouts, and encrypted credential injection. Static headers cannot override credentials, cookies, routing, proxy, forwarding, or hop-by-hop headers.
|
||||
|
||||
The executor caps request bodies at 10 KiB and response bodies at 1 MiB. Parsed JSON is limited to depth 32 and 50,000 nodes;
|
||||
each network hop has a ten-second timeout, queries follow at most three same-origin redirects, actions do not follow redirects,
|
||||
and the total request deadline is 45 seconds.
|
||||
|
||||
The limiter allows four concurrent requests per user/item and eight per definition. Per minute, a user/item can make up to 60
|
||||
queries, 10 actions, or 3 DELETE actions; a definition can receive up to 240, 40, or 12 of those request categories.
|
||||
|
||||
Queries default to view permission and actions to modify. DELETE always requires full permission and confirmation. Actions are never cached, retried, or run on load.
|
||||
|
||||
Management and runtime are intentionally separate. A Homarr administrator with board-modify permission places and
|
||||
configures the widget. After placement, query and action authorization follows the request's board permission. Public
|
||||
boards may execute queries for anonymous viewers, but mutations stay authenticated. This includes shared-cache
|
||||
invalidation: `RefreshButton` is disabled for anonymous viewers, while ordinary scheduled query rendering remains
|
||||
available.
|
||||
invalidation: `RefreshButton` is disabled only without an active item or preview session, or in edit mode, while ordinary
|
||||
scheduled query rendering remains available to anonymous viewers.
|
||||
|
||||
Responses use bounded, versioned shared-Redis cache keys. Action invalidation advances the shared version so every
|
||||
Homarr replica stops serving the previous response without scanning or deleting a key prefix.
|
||||
@@ -41,7 +41,8 @@ The workbench marks the invalid field while you type. An invalid URL does not re
|
||||
|
||||
An invalid definition disables widget preview and Save/Create. The Data, Options, Actions, and Diagnostics tabs remain available so you can correct it.
|
||||
|
||||
The Diagnostics tab shows the current schema errors.
|
||||
The Diagnostics tab shows current schema and JSX errors, accessibility checks, and (after a preview) the redacted request
|
||||
journal. It does not expose credentials or raw secret values.
|
||||
|
||||
## Workshop unavailable
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -23,9 +23,9 @@ approval before running mutating procedures.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The MCP endpoint lives under the **API** page at `Management` → `Tools` → `API` → **MCP** tab.
|
||||
The MCP endpoint lives under the **API** page at `Management` → `Tools` → `API` → **AI / MCP** tab.
|
||||
|
||||

|
||||

|
||||
|
||||
### 1. Create an API Key
|
||||
|
||||
|
||||
@@ -220,7 +220,8 @@ Clicking the `Copy & close` button will copy the URL to your clipboard.
|
||||
|
||||
### Self-onboarding
|
||||
|
||||
Clicking the invitation link will bring up a form with fields for the username and password:
|
||||
Clicking the invitation link opens a centered registration form that uses the same visual shell as Homarr's onboarding
|
||||
and sign-in pages, with fields for the username and password:
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ import piHoleApp from "./img/pi-hole-ping-icon.png";
|
||||
|
||||
### Interactions
|
||||
|
||||
App tiles stay compact and open their configured destination directly. Right-click a tile to inspect its status or change its settings when the board and your preferences allow it.
|
||||
App tiles stay compact and open their configured destination directly. They do not use the shared widget context menu;
|
||||
configure the app and its status check from the tile's edit actions.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ 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 ".";
|
||||
import assistantWidgetImage from "../../management/img/assistant-widget.png";
|
||||
|
||||
<WidgetHeader widget={assistantWidget} categories={["Productivity"]} />
|
||||
|
||||
@@ -16,7 +16,18 @@ board. Messages, conversations, tool calls, approvals, files, mentions, model co
|
||||
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.
|
||||
on an instance without an enabled model see an unavailable state instead of a broken composer. Signed-out users see
|
||||
**Sign in**; a configured provider without a saved model or with **Enable Assistant** off remains unavailable until
|
||||
the administrator saves a working connection, selects a model, and enables the assistant.
|
||||
|
||||
<img
|
||||
src={assistantWidgetImage}
|
||||
alt="Assistant widget embedded on a dashboard with its conversation header, automatic approvals control, model selector, and composer"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
The current preview capture labels the board-embedded surface: its header exposes conversation history, refresh,
|
||||
automatic approvals, and new-conversation controls, while the footer exposes the model, thinking mode, and composer.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
@@ -28,15 +39,8 @@ touch devices. When a board layout does not provide enough height for conversati
|
||||
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.
|
||||
The widget follows whichever conversation is active in the user-menu panel, `Shift + A`, or the command menu. This
|
||||
keeps every assistant entry point synchronized without storing a user's private conversation details in board settings.
|
||||
|
||||
### Conversation features
|
||||
|
||||
@@ -50,10 +54,16 @@ active conversation silently: select **Open conversation** inside the widget bef
|
||||
- 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.
|
||||
- Change the model and reasoning effort for the active conversation. A model choice is saved per persisted thread,
|
||||
does not change the administrator's default, and is restored when that thread is selected again; a new thread
|
||||
starts from the configured default.
|
||||
- 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.
|
||||
|
||||
Automatic approvals are off by default, reset when switching conversations, and never select a neutral choice. A
|
||||
structured question shows **Other** only when the tool allows it (`allowOther` is not `false`); selecting **Other**
|
||||
opens a manual freeform response even when automatic approvals are enabled.
|
||||
@@ -4,12 +4,11 @@ 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";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
import { beszelSystemGridWidget } from ".";
|
||||
import { beszelIntegration } from "@site/docs/integrations/beszel";
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
|
||||
|
||||
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
|
||||
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. Hover over or focus the widget controls to switch between the realtime Live view and historical periods ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. Use the widget controls to switch between the realtime Live view and historical periods ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@ 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";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
import { beszelSystemTableWidget } from ".";
|
||||
import { beszelIntegration } from "@site/docs/integrations/beszel";
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||
|
||||
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.
|
||||
The bookmarks widget allows you to display multiple links in a single widget.
|
||||
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
|
||||
For links that do not need an App, add their URLs under **Custom links** instead.
|
||||
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.
|
||||
|
||||
|
||||
@@ -47,9 +47,15 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
{
|
||||
name: "Bookmarks",
|
||||
description: "List of bookmarks to display in the widget.",
|
||||
values: "Select of all your apps and order them as you like.",
|
||||
values: "Select one or more Apps directly, then order them as you like.",
|
||||
defaultValue: "No bookmarks selected",
|
||||
},
|
||||
{
|
||||
name: "Custom links",
|
||||
description: "URLs to display without first creating an App.",
|
||||
values: "List of URLs.",
|
||||
defaultValue: "No custom links",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -31,16 +31,12 @@ the same data is requested through Homarr's MCP or Assistant tools.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import popover from "./img/popover.png";
|
||||
import withIntegrations from "./img/with-integrations.png";
|
||||
import withoutIntegrations from "./img/without-integrations.png";
|
||||
import currentView from "./img/current-view.png";
|
||||
import advancedView from "./img/advanced-view.png";
|
||||
|
||||
<div className="flex gap-1">
|
||||
<img src={withIntegrations} alt="calendar with-integrations big-size" width={256 + 4} height={256 + 4} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={popover} alt="calendar popover-details the-mandalorian" width={128 * 3} height={128} />
|
||||
<img src={withoutIntegrations} alt="calendar without-integrations small-size" width={256} height={128} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={currentView} alt="Calendar month view with marked events" width={513} height={303} />
|
||||
<img src={advancedView} alt="Calendar advanced view with grouped events" width={800} height={484} />
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
@@ -32,7 +32,8 @@ Credentials remain on the server.
|
||||
|
||||
:::note
|
||||
Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker.
|
||||
Action buttons can only be executed by administrators.
|
||||
Action buttons require the request's minimum board permission, which is `modify` by default. Anonymous viewers cannot run
|
||||
mutations. DELETE additionally requires full permission and confirmation.
|
||||
:::
|
||||
|
||||
### Permissions and actions
|
||||
|
||||
@@ -22,7 +22,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
|
||||
|
||||
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's width and height.
|
||||
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's width and height. Bulk actions keep successful results visible and report the number of failures.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 31 KiB |
@@ -7,19 +7,15 @@ hide_title: true
|
||||
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 { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
import { dockerContainersWidget } from ".";
|
||||
import { dockerIntegration } from "@site/docs/integrations/docker";
|
||||
|
||||
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
||||
|
||||
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
||||
|
||||
:::info
|
||||
This widget can only be used with administrator privileges.
|
||||
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
|
||||
:::
|
||||
|
||||
### Screenshots
|
||||
@@ -28,16 +24,6 @@ import dockerTable from "./img/table.png";
|
||||
|
||||
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dockerIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Interactions
|
||||
|
||||
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
||||
@@ -49,6 +35,8 @@ Each container row has an action menu, accessible via the three-dot icon or by r
|
||||
- **Remove** - permanently remove the container after a second confirmation click
|
||||
- **Add to Homarr** - create a bookmark app from the container
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
@@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = {
|
||||
path: "../../widgets/docker-containers",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Docker environments",
|
||||
description: "Select one or more automatically discovered Docker environments, or leave empty to show all",
|
||||
values: "Any Docker socket or host configured for Homarr",
|
||||
defaultValue: "All Docker environments",
|
||||
},
|
||||
{
|
||||
name: "Columns to show",
|
||||
description: "Select which columns are visible in the table",
|
||||
|
||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 76 KiB |
@@ -21,36 +21,79 @@ import { downloadsWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={downloadsWidget} categories={["Torrent client", "Usenet client", "Downloads"]} />
|
||||
|
||||
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface.
|
||||
This widget displays torrent, Usenet, and miscellaneous download jobs, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import downloadsCard from "./img/screenshot.png";
|
||||
import contextMenuImg from "./img/context-menu.png";
|
||||
import advancedViewImg from "./img/advanced-view.png";
|
||||
import filterPanelImg from "./img/filter-panel.png";
|
||||
|
||||
<div>
|
||||
<img src={downloadsCard} alt="Downloads widget showing torrent and usenet items" width={128 * 4} />
|
||||
</div>
|
||||
|
||||
#### Right-click context menu
|
||||
#### Download-row context menu
|
||||
|
||||
<div>
|
||||
<img src={contextMenuImg} alt="Downloads widget context menu with pause, delete, and copy actions" width={128 * 4} />
|
||||
<img src={contextMenuImg} alt="Downloads row context menu with pause, delete, and copy actions" width={128 * 4} />
|
||||
</div>
|
||||
|
||||
#### Advanced view
|
||||
|
||||
<div>
|
||||
<img
|
||||
src={advancedViewImg}
|
||||
alt="Downloads widget in advanced view with stats, sortable columns, and expanded details"
|
||||
width={128 * 6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
#### Advanced filter panel
|
||||
|
||||
<div>
|
||||
<img src={filterPanelImg} alt="Downloads advanced view with the status filter panel open" width={128 * 6} />
|
||||
</div>
|
||||
|
||||
### Features
|
||||
|
||||
- **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
|
||||
- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column
|
||||
- **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction
|
||||
- **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** — pause, resume, delete downloads, or copy download names directly from the context menu
|
||||
- **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration
|
||||
- **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
|
||||
- **Global stats bar** — show queue progress, downloaded/total size, transfer speeds, global torrent ratio, and per-state counts in compact mode; advanced view always includes it
|
||||
- **Client filtering** — filter downloads by integration or status using the footer filter panel
|
||||
- **Global controls** — pause/resume all downloads across all clients from the footer bar
|
||||
- **Global controls** — pause/resume actionable queues from the footer bar, limited to selected integrations where you have Interact access
|
||||
- **Status indicators** — color-coded badges show download state at a glance
|
||||
- **Speed overview** — total download and upload speeds displayed in the footer
|
||||
- **Speed overview** — total download and upload speeds for the selected clients are displayed in the footer
|
||||
|
||||
:::note
|
||||
|
||||
Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state
|
||||
columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also
|
||||
hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions,
|
||||
keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available
|
||||
from the footer.
|
||||
|
||||
The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and
|
||||
state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the
|
||||
selected torrent clients. The configured category/label filter can optionally be applied to the global torrent ratio
|
||||
through **Use category filter for global torrent ratio**; footer client/status filters do not change that ratio.
|
||||
|
||||
The per-item **Ratio** is sent bytes divided by the item's total size. **Global Ratio** is total uploaded bytes divided
|
||||
by total downloaded bytes.
|
||||
|
||||
Mutation controls depend on the integration's Interact permission. Users without that permission can still view rows and
|
||||
use the row menu's copy actions. `slskd` is list-only: its pause, resume, and delete operations are no-ops, so those
|
||||
controls have no effect for that client even when Interact permission exposes them.
|
||||
|
||||
The screenshot above is a Downloads row menu, separate from the shared widget context menu. When board right-click menus
|
||||
are enabled, the shared menu provides widget-level actions such as advanced view, widget options, refresh, and settings.
|
||||
|
||||
:::
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
@@ -82,20 +125,21 @@ import contextMenuImg from "./img/context-menu.png";
|
||||
},
|
||||
{
|
||||
integration: slskdIntegration,
|
||||
note: "Soulseek client (read-only)",
|
||||
note: "Soulseek client (list-only; mutation controls are no-ops)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Interactions
|
||||
|
||||
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
|
||||
- **Right-click** any download to copy its name or ID; pause, resume, and delete are available only with Interact access
|
||||
- **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** all downloads using the footer controls
|
||||
- **Filter** by integration or status using the filter button in the footer
|
||||
- **Sort** by clicking column headers (with persistent sort direction)
|
||||
- **Stats toggle** — show or hide the global stats bar in compact mode; advanced view always shows the stats bar without
|
||||
changing the compact choice
|
||||
- **Pause / Resume** actionable queues using the footer controls
|
||||
- **Filter** rows by integration or status using the filter button in the footer
|
||||
- **Sort** by clicking sortable column headers; the default sort is configured per widget, while header changes apply to the current view
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -30,19 +30,20 @@ export const downloadsWidget: WidgetDefinition = {
|
||||
{
|
||||
name: "Columns to show",
|
||||
description:
|
||||
"Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.",
|
||||
"Select the columns you want to display in the widget. Compact layouts automatically hide/show selected columns based on widget width and height; advanced view keeps the selected columns visible.",
|
||||
values: `List of columns: ${columnsList.join(", ")}`,
|
||||
defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "),
|
||||
},
|
||||
{
|
||||
name: "Default sort column",
|
||||
description: "The column used for initial sorting when the widget loads.",
|
||||
description:
|
||||
"The column used for initial sorting when the widget loads. Clicking a column header changes the current view only.",
|
||||
defaultValue: "progress",
|
||||
values: `List of columns: ${sortColumns.join(", ")}`,
|
||||
},
|
||||
{
|
||||
name: "Invert sorting",
|
||||
description: "This will invert the default sorting order.",
|
||||
description: "This sets the initial sorting direction.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
@@ -66,8 +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 a download speed below the specified threshold.",
|
||||
description: "This will hide completed torrent entries whose upload speed is below the specified threshold.",
|
||||
values: "Any number above 0, 0 to disable",
|
||||
defaultValue: "0",
|
||||
},
|
||||
@@ -85,15 +85,16 @@ export const downloadsWidget: WidgetDefinition = {
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Use filter to calculate Ratio",
|
||||
description: "This will use the category filter when calculating the global torrent ratio.",
|
||||
name: "Use category filter for global torrent ratio",
|
||||
description:
|
||||
"When enabled, the configured category/label filter is applied to the global torrent ratio. Footer client/status filters do not change this ratio.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Limit items per integration",
|
||||
description: "This will limit the number of items shown per integration, not globally.",
|
||||
values: "Any number above 1",
|
||||
values: "Any whole number from 1 upward",
|
||||
defaultValue: "50",
|
||||
},
|
||||
],
|
||||
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 22 KiB |
@@ -31,8 +31,12 @@ Sessions without user metadata remain visible and are labeled as an unknown user
|
||||
### Screenshots
|
||||
|
||||
import mediaTable from "./img/table.png";
|
||||
import mediaAdvancedView from "./img/advanced-view.png";
|
||||
|
||||
<img src={mediaTable} alt="media-server-streams" width={128 * 3 * 1.5} height={128 * 1.5} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={mediaTable} alt="Media server compact streams table" width={303} height={198} />
|
||||
<img src={mediaAdvancedView} alt="Media server advanced view with stream summary" width={800} height={484} />
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -15,17 +15,20 @@ import { weatherWidget } from ".";
|
||||
|
||||
The Weather Widget uses your location to display the current, highest and lowest temperature.
|
||||
In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
|
||||
The advanced view always combines current conditions with the full seven-day forecast, including humidity, wind,
|
||||
gusts, sunrise and sunset. Both views reuse the same cached Open Meteo response.
|
||||
The compact forecast shows up to the configured number of days. The advanced view combines current conditions with the
|
||||
available daily forecast, including humidity, wind, gusts, sunrise and sunset. Both views reuse the same cached Open
|
||||
Meteo response.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import forecastWeather from "./img/forecast.png";
|
||||
import currentWeather from "./img/current.png";
|
||||
import advancedWeather from "./img/advanced-view.png";
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={forecastWeather} alt="weather forecast-5-days" width={128 * 4.5} />
|
||||
<img src={currentWeather} alt="weather current" width={128 * 3} />
|
||||
<img src={forecastWeather} alt="Weather compact forecast" width={238} height={112} />
|
||||
<img src={currentWeather} alt="Weather compact current conditions" width={198} height={93} />
|
||||
<img src={advancedWeather} alt="Weather advanced view with seven-day forecast details" width={800} height={484} />
|
||||
</div>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
@@ -38,7 +38,6 @@ 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`
|
||||
@@ -48,9 +47,9 @@ Workshop moderator promotion, logs, tests, backup, restore, stopping, and intent
|
||||
|
||||
## 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:
|
||||
Docusaurus reads `HOMARR_WEBSITE_URL`, `WORKSHOP_API_URL`, and `WORKSHOP_WEB_URL` when it starts or builds. Homarr uses
|
||||
`WORKSHOP_WEB_URL` for public Workshop links while API and documentation origins remain independent. These values do
|
||||
not rely on `NEXT_PUBLIC_*` build variables. Test separate origins before release:
|
||||
|
||||
```sh
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
|
||||
|
||||
@@ -32,14 +32,12 @@ 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.
|
||||
For a saved Custom Widget, open **Management → Custom Widgets**, use its action menu, and select **Publish to
|
||||
Workshop**. Review the public listing details and source URLs, then publish the credential-free export. Custom CSS and
|
||||
manual submissions can be shared directly from the Workshop website with **Share yours**.
|
||||
|
||||
Export Custom Widget JSON from **Management → Custom Widgets → Export**. You can edit a submission later without
|
||||
changing its Workshop URL.
|
||||
Publishing creates the listing immediately. To update or delete it, review reports, or mark it outdated, open the
|
||||
Workshop website, sign in, and select **Yours**. Editing a submission does not change its Workshop URL.
|
||||
|
||||
## Voting, comments, and reports
|
||||
|
||||
|
||||
@@ -37,22 +37,23 @@ store duplicate profile metadata or avatar files. Configure SMTP for comment, re
|
||||
|
||||
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 |
|
||||
| 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 docs | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop links in 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
|
||||
release; `WORKSHOP_API_URL` takes precedence for API calls and `WORKSHOP_WEB_URL` takes precedence for public Workshop
|
||||
links. 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.
|
||||
|
||||
@@ -100,7 +101,6 @@ preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its defaul
|
||||
```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=*
|
||||
```
|
||||
@@ -118,8 +118,8 @@ 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`.
|
||||
chosen hostname already serves Homarr itself. In that case, replace the hostname in all three URL values. Homarr
|
||||
derives the public Workshop page by appending `/workshop` to `HOMARR_WEBSITE_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
|
||||
|
||||
@@ -1,62 +1,10 @@
|
||||
import { IconArrowsMaximize, IconLayoutGrid, IconMouse2, IconPlus } from "@tabler/icons-react";
|
||||
|
||||
const advancedInteraction = {
|
||||
icon: IconArrowsMaximize,
|
||||
title: "Advanced view",
|
||||
description:
|
||||
"Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.",
|
||||
} as const;
|
||||
|
||||
const standardInteractions = [
|
||||
{
|
||||
icon: IconMouse2,
|
||||
title: "Widget actions",
|
||||
description:
|
||||
"Right-click for live query status, refresh, settings, quick options, and actions supported by the widget.",
|
||||
},
|
||||
{
|
||||
icon: IconPlus,
|
||||
title: "Place it precisely",
|
||||
description:
|
||||
"With board changes allowed, right-click or touch and hold empty grid space to add there. Keyboard users can press Shift + F10.",
|
||||
},
|
||||
{
|
||||
icon: IconLayoutGrid,
|
||||
title: "Adaptive detail",
|
||||
description:
|
||||
"Resize a widget to tune its density. Compact views keep primary status visible, then reveal details and controls as space or intent allows.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?: boolean }) => {
|
||||
const interactions = advancedView ? [advancedInteraction, ...standardInteractions] : standardInteractions;
|
||||
if (!advancedView) return null;
|
||||
|
||||
return (
|
||||
<details className="group my-6 overflow-hidden rounded-xl border border-solid border-[#e5e7eb] bg-slate-50/70 dark:border-[#333] dark:bg-gray-900/40">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 font-semibold marker:hidden">
|
||||
<span>Power-user controls</span>
|
||||
<span className="text-xs font-normal text-[#696969] group-open:hidden dark:text-[#999]">Show shortcuts</span>
|
||||
<span className="hidden text-xs font-normal text-[#696969] group-open:inline dark:text-[#999]">
|
||||
Hide shortcuts
|
||||
</span>
|
||||
</summary>
|
||||
<div
|
||||
className={`grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-2 ${advancedView ? "xl:grid-cols-4" : "xl:grid-cols-3"} dark:border-[#333]`}
|
||||
>
|
||||
{interactions.map(({ icon: Icon, title, description }) => (
|
||||
<div key={title} className="rounded-lg bg-white p-3 shadow-sm dark:bg-gray-950">
|
||||
<Icon aria-hidden size={20} stroke={1.5} className="mb-2 stroke-red-500" />
|
||||
<div className="font-semibold">{title}</div>
|
||||
<p className="mb-0 mt-1 text-sm text-[#696969] dark:text-[#999]">{description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{advancedView && (
|
||||
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
|
||||
Advanced view reuses the widget's live data and cache. The rest of the board stays visible but dimmed. On
|
||||
touch devices, use the widget menu; compact-only widgets adapt in place instead.
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
<p>
|
||||
<strong>Supports advanced view.</strong> See the{" "}
|
||||
<a href="/docs/advanced/keyboard-shortcuts/">advanced keyboard shortcuts</a>.
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -46,7 +46,7 @@ const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
preloadEntriesOnStart: false,
|
||||
preloadEntriesOnStart: true,
|
||||
turbopackFileSystemCacheForBuild: true,
|
||||
useTypeScriptCli: true,
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"@mantine/form": "catalog:",
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@mantine/modals": "catalog:",
|
||||
"@mantine/notifications": "catalog:",
|
||||
"@mantine/tiptap": "catalog:",
|
||||
"@modelcontextprotocol/server": "catalog:",
|
||||
"@rexa-developer/tiks": "catalog:",
|
||||
|
||||
@@ -62,13 +62,16 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<form autoComplete="on" onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="lg">
|
||||
<TextInput
|
||||
label={t("field.username.label")}
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
{...form.getInputProps("username")}
|
||||
name="username"
|
||||
/>
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
@@ -76,6 +79,7 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
||||
id="password"
|
||||
autoComplete="new-password"
|
||||
{...form.getInputProps("password")}
|
||||
name="password"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
@@ -83,6 +87,7 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
||||
id="password-confirm"
|
||||
autoComplete="new-password"
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
name="password-confirmation"
|
||||
/>
|
||||
<Button type="submit" fullWidth loading={isPending}>
|
||||
{t("action.register.label")}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconUserPlus } from "@tabler/icons-react";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
|
||||
interface InviteUsagePageProps {
|
||||
@@ -48,25 +48,14 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
const t = await getScopedI18n("user.page.invite");
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center" mt="xl">
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text size="sm" c="gray.5" ta="center">
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<RegistrationForm invite={invite} />
|
||||
</Card>
|
||||
<Text size="xs" c="gray.5" ta="center">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
|
||||
{t("description", { username: invite.creator.name! })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
<OnboardingAuthShell
|
||||
title={t("title")}
|
||||
description={t("subtitle")}
|
||||
icon={<IconUserPlus size={24} />}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
footer={t("description", { username: invite.creator.name! })}
|
||||
>
|
||||
<RegistrationForm invite={invite} />
|
||||
</OnboardingAuthShell>
|
||||
);
|
||||
}
|
||||
@@ -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, useMounted } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
@@ -32,7 +32,6 @@ 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: {
|
||||
@@ -116,24 +115,31 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
<Stack gap="lg">
|
||||
{credentialInputsVisible && (
|
||||
<>
|
||||
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}>
|
||||
<form
|
||||
autoComplete="on"
|
||||
onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<TextInput
|
||||
label={t("field.username.label")}
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
{...form.getInputProps("name")}
|
||||
name="username"
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("field.password.label")}
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
{...form.getInputProps("password")}
|
||||
name="password"
|
||||
/>
|
||||
|
||||
{providers.includes("credentials") && (
|
||||
<Stack gap="sm">
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="credentials">
|
||||
<SubmitButton isPending={isPending} form={form} provider="credentials">
|
||||
{t("action.login.label")}
|
||||
</SubmitButton>
|
||||
<PasswordForgottenCollapse username={form.values.name} />
|
||||
@@ -141,7 +147,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("ldap") && (
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="ldap">
|
||||
<SubmitButton isPending={isPending} form={form} provider="ldap">
|
||||
{t("action.login.labelWith", { provider: "LDAP" })}
|
||||
</SubmitButton>
|
||||
)}
|
||||
@@ -152,7 +158,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("oidc") && (
|
||||
<Button fullWidth variant="light" disabled={!isMounted} onClick={async () => await signInAsync("oidc")}>
|
||||
<Button type="button" fullWidth variant="light" disabled={isPending} onClick={() => void signInAsync("oidc")}>
|
||||
{t("action.login.labelWith", { provider: oidcClientName })}
|
||||
</Button>
|
||||
)}
|
||||
@@ -162,23 +168,23 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
};
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isMounted: boolean;
|
||||
isPending: boolean;
|
||||
form: UseFormReturnType<FormType>;
|
||||
provider: "credentials" | "ldap";
|
||||
}
|
||||
|
||||
const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const isCurrentProviderActive = form.getValues().provider === provider;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
name={provider}
|
||||
name="provider"
|
||||
value={provider}
|
||||
fullWidth
|
||||
onClick={() => form.setFieldValue("provider", provider)}
|
||||
loading={isPending && isCurrentProviderActive}
|
||||
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
|
||||
disabled={isPending && !isCurrentProviderActive}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
|
||||
import { Alert, Code, Text } from "@mantine/core";
|
||||
import { IconLogin } from "@tabler/icons-react";
|
||||
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { OnboardingAuthShell } from "@homarr/onboarding";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
|
||||
import { env as appEnv } from "~/env";
|
||||
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { LoginForm } from "./_login-form";
|
||||
|
||||
interface LoginProps {
|
||||
@@ -29,33 +29,20 @@ export default async function Login(props: LoginProps) {
|
||||
const t = await getScopedI18n("user.page.login");
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center" mt="xl">
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text size="sm" c="gray.5" ta="center">
|
||||
{t("subtitle")}
|
||||
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
|
||||
{appEnv.DEMO_MODE ? (
|
||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
|
||||
<Text size="sm" fw={500}>
|
||||
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
|
||||
</Text>
|
||||
</Stack>
|
||||
{appEnv.DEMO_MODE && (
|
||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
|
||||
<Text size="sm" fw={500}>
|
||||
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
|
||||
callbackUrl={searchParams.callbackUrl ?? "/"}
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Alert>
|
||||
) : null}
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
|
||||
callbackUrl={searchParams.callbackUrl ?? "/"}
|
||||
/>
|
||||
</OnboardingAuthShell>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export const ClientBoard = () => {
|
||||
.map(getLogicalTrackSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
@@ -78,7 +78,7 @@ export const ClientBoard = () => {
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
|
||||
@@ -44,9 +44,11 @@ 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 sessionPromise = auth();
|
||||
const boardPromise = getInitialBoard(resolvedParams);
|
||||
const session = await sessionPromise;
|
||||
|
||||
const board = await getInitialBoard(resolvedParams).catch((error) => {
|
||||
const board = await boardPromise.catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
@@ -165,7 +165,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
setIsEnteringEditMode(true);
|
||||
try {
|
||||
await prepareEditorAsync();
|
||||
startTransition(open);
|
||||
open();
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("notification.loadError.title"),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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,7 +2,6 @@
|
||||
|
||||
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";
|
||||
@@ -24,11 +23,10 @@ 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 && isDesktop ? requestedLayoutId : null;
|
||||
const layoutOverrideId = isLayoutEditRequest ? requestedLayoutId : null;
|
||||
|
||||
return (
|
||||
<BoardProvider
|
||||
|
||||
@@ -11,6 +11,7 @@ import "~/styles/color-scheme.scss";
|
||||
import "~/styles/scroll-area.scss";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { ColorSchemeScript } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
@@ -162,17 +163,16 @@ export default async function Layout(props: {
|
||||
]);
|
||||
|
||||
return (
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
lang={locale}
|
||||
dir={direction}
|
||||
data-mantine-color-scheme={colorScheme}
|
||||
style={{
|
||||
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
|
||||
}}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<ColorSchemeScript defaultColorScheme={colorScheme} />
|
||||
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
|
||||
{session ? (
|
||||
<>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
@@ -140,6 +141,7 @@ const ConfigurationSkeleton = ({ label }: { label: string }) => (
|
||||
|
||||
export const AssistantConfiguration = () => {
|
||||
const t = useScopedI18n("management.page.settings.section.assistant");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
|
||||
const [provider, setProvider] = useState<AssistantProvider>("homarr");
|
||||
@@ -268,6 +270,7 @@ export const AssistantConfiguration = () => {
|
||||
utils.assistant.getAvailability.invalidate(),
|
||||
utils.assistant.getRuntimeOptions.invalidate(),
|
||||
]);
|
||||
router.refresh();
|
||||
showSuccessNotification({
|
||||
title: t("notification.saved.title"),
|
||||
message: t("notification.saved.message"),
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
@@ -21,9 +23,10 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
|
||||
import { downloadJson } from "~/components/custom-widgets/download";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -40,12 +43,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [publishOpened, publishControls] = useDisclosure(false);
|
||||
const [migrationOpened, migrationControls] = useDisclosure(false);
|
||||
const migrationFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
|
||||
const copyMigrationPrompt = async () => {
|
||||
try {
|
||||
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
|
||||
@@ -62,30 +64,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const copyLegacyDefinition = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
|
||||
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
||||
showSuccessNotification({
|
||||
title: t("action.copyLegacyDefinition"),
|
||||
message: t("notification.legacyDefinitionCopied"),
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.copyLegacyDefinition"),
|
||||
message: error instanceof Error ? error.message : t("notification.legacyDefinitionCopyError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const queueMigratedWidget = (text: string) => {
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
showErrorNotification({ title: t("action.migrate"), message: formatCustomWidgetImportIssues(result.issues) });
|
||||
return;
|
||||
}
|
||||
setMigratedWidget(result.widget);
|
||||
migrationControls.open();
|
||||
};
|
||||
const notifyMigrationError = () =>
|
||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||
|
||||
const pasteMigratedWidget = async () => {
|
||||
try {
|
||||
queueMigratedWidget(await navigator.clipboard.readText());
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
notifyMigrationError();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -94,15 +107,10 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
try {
|
||||
queueMigratedWidget(loadEvent.target?.result as string);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
notifyMigrationError();
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||
});
|
||||
reader.addEventListener("error", notifyMigrationError);
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
@@ -110,18 +118,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${widget.name}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
downloadJson(data, `${widget.name}.json`);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.export"), message: t("notification.exportError") });
|
||||
}
|
||||
};
|
||||
|
||||
const exportLegacyDefinition = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
|
||||
downloadJson(data, `${widget.name}.legacy.json`);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.exportLegacyDefinition"), message: t("notification.exportError") });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.widget.customApi.getData.cancel();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
duplicateMutation.mutate(
|
||||
{ id: widget.id },
|
||||
@@ -146,22 +177,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
title: t("action.delete"),
|
||||
children: t("action.deleteConfirm", { name: widget.name }),
|
||||
onConfirm: () => {
|
||||
const callbacks = {
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
deleteMutation.mutate(
|
||||
{ id: widget.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
};
|
||||
deleteMutation.mutate({ id: widget.id }, callbacks);
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -177,6 +210,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Dropdown>
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Menu.Item
|
||||
onClick={handleToggleEnabled}
|
||||
leftSection={widget.enabled ? <IconPlayerPause {...iconProps} /> : <IconPlayerPlay {...iconProps} />}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
{widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
@@ -187,7 +228,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/publish/${widget.id}`}
|
||||
leftSection={<IconBuildingStore {...iconProps} />}
|
||||
>
|
||||
{t("action.publishWorkshop")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
@@ -195,6 +240,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
)}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<Menu.Item onClick={() => void copyLegacyDefinition()} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("action.copyLegacyDefinition")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void exportLegacyDefinition()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.exportLegacyDefinition")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
|
||||
{t("action.copyMigrationPrompt")}
|
||||
</Menu.Item>
|
||||
@@ -222,7 +274,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<input
|
||||
|
||||
@@ -144,6 +144,7 @@ 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}
|
||||
|
||||
@@ -19,12 +19,18 @@ describe("legacy custom widget migration UI", () => {
|
||||
});
|
||||
|
||||
it("replaces the legacy record through migration and refreshes every consumer", () => {
|
||||
// The import pipeline is shared by the dialog and the Workshop install page.
|
||||
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
|
||||
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
|
||||
|
||||
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
|
||||
expect(dialog).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(dialog).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(importer).toMatch(
|
||||
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
|
||||
);
|
||||
expect(importer).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(importer).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
expect(dialog).toContain("useCustomWidgetImport({");
|
||||
expect(dialog).toContain("legacyId,");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
|
||||
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
|
||||
@@ -31,173 +31,164 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{definitions.map((def) => (
|
||||
<CustomWidgetCard key={def.id} widget={def} />
|
||||
<ManageCollectionList ariaLabel={t("page.list.title")}>
|
||||
{definitions.map((definition) => (
|
||||
<CustomWidgetRow key={definition.id} widget={definition} />
|
||||
))}
|
||||
</Stack>
|
||||
</ManageCollectionList>
|
||||
);
|
||||
};
|
||||
|
||||
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.widget.customApi.getData.cancel();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
|
||||
const origins = [...new Set(widget.sources.map((source) => source.origin))];
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
withBorder={!widget.valid}
|
||||
bd={
|
||||
widget.migrationRequired
|
||||
? "1px solid var(--mantine-color-yellow-6)"
|
||||
: !widget.valid
|
||||
? "1px solid var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
{widget.iconUrl ? (
|
||||
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<Avatar size={36} radius="sm" color="red">
|
||||
<IconApi size={18} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
<>
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar
|
||||
size={40}
|
||||
radius="sm"
|
||||
src={widget.iconUrl}
|
||||
color={widget.valid ? "pink" : "red"}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
alt=""
|
||||
>
|
||||
<IconApi size={20} stroke={1.5} />
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
}
|
||||
badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />}
|
||||
description={
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{widget.description || t("page.list.noDescription")}
|
||||
</Text>
|
||||
}
|
||||
metadata={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("page.list.requestCount", { count: widget.requestCount })}
|
||||
</Text>
|
||||
{widget.migrationRequired ? (
|
||||
<Text size="xs" c="yellow.8">
|
||||
{t("page.list.migrationDescription")}
|
||||
{origins.map((origin) => (
|
||||
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
|
||||
· {origin}
|
||||
</Text>
|
||||
) : !widget.valid ? (
|
||||
<Text size="xs" c="red">
|
||||
{t("page.list.invalidDefinitionDescription")}
|
||||
</Text>
|
||||
) : null}
|
||||
{widget.sources[0] && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{widget.migrationRequired ? (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : widget.valid ? (
|
||||
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||
JSX · {widget.requestCount}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={widget.validationIssues
|
||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||
.join("\n")}
|
||||
multiline
|
||||
maw={520}
|
||||
>
|
||||
<Badge
|
||||
color="red"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.invalidDefinition")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{widget.missingSecrets.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={widget.enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
))}
|
||||
</Group>
|
||||
}
|
||||
actions={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{widget.valid && (
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("action.edit")}
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
{t("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
actionsAlignment="center"
|
||||
/>
|
||||
{widget.valid && (
|
||||
<CustomWidgetSourceSetupDialog
|
||||
definitionId={widget.id}
|
||||
opened={sourceSetupOpened}
|
||||
onClose={sourceSetupControls.close}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything that decides whether this widget can actually run right now. */
|
||||
function CustomWidgetStatusBadges({
|
||||
widget,
|
||||
onConfigureSources,
|
||||
}: {
|
||||
widget: WidgetDef;
|
||||
onConfigureSources(): void;
|
||||
}) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
if (widget.migrationRequired) {
|
||||
return (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge color="yellow" size="sm" variant="light" leftSection={<IconSparkles size={12} />}>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.valid) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={widget.validationIssues
|
||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||
.join("\n")}
|
||||
multiline
|
||||
maw={520}
|
||||
>
|
||||
<Badge color="red" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
|
||||
{t("page.list.invalidDefinition")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{widget.enabled ? (
|
||||
<Badge color="green" size="sm" variant="light">
|
||||
{t("page.list.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label={t("page.list.disabledDescription")}>
|
||||
<Badge color="gray" size="sm" variant="light">
|
||||
{t("page.list.disabled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{widget.missingSecrets.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ 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> }) => {
|
||||
@@ -32,7 +33,12 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
|
||||
return <div>widget-renderer</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
|
||||
vi.mock("./_code-editor", () => ({
|
||||
CodeEditor: (props: { label: string; readOnly?: boolean }) => {
|
||||
codeEditorProps(props);
|
||||
return <div>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
|
||||
|
||||
let root: Root;
|
||||
@@ -109,6 +115,7 @@ 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,6 +172,7 @@ 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>
|
||||
@@ -251,6 +252,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
label={t("journal")}
|
||||
language="json"
|
||||
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
|
||||
readOnly
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -87,7 +87,9 @@ export function CustomWidgetSourcesEditor({
|
||||
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
|
||||
};
|
||||
const addSource = () => {
|
||||
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
|
||||
let suffix = sources.length + 1;
|
||||
while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1;
|
||||
const id = sources.length === 0 ? "default" : `source-${suffix}`;
|
||||
form.setFieldValue(
|
||||
"sources",
|
||||
JSON.stringify(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
const tabs = {
|
||||
installed: "/manage/custom-widgets",
|
||||
workshop: "/manage/custom-widgets/workshop",
|
||||
} as const;
|
||||
|
||||
/** Switches between the widgets you have installed and the community Workshop. */
|
||||
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
|
||||
const t = useScopedI18n("customWidget.page.tabs");
|
||||
|
||||
return (
|
||||
<Tabs value={tabs[active]}>
|
||||
<Tabs.List aria-label={t("ariaLabel")}>
|
||||
<Tabs.Tab
|
||||
value={tabs.installed}
|
||||
leftSection={<IconApi size={16} stroke={1.5} />}
|
||||
renderRoot={(props) => <Link href={tabs.installed} {...props} />}
|
||||
>
|
||||
{t("installed")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value={tabs.workshop}
|
||||
leftSection={<IconBuildingStore size={16} stroke={1.5} />}
|
||||
renderRoot={(props) => <Link href={tabs.workshop} {...props} />}
|
||||
>
|
||||
{t("workshop")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||