Compare commits

...
Author SHA1 Message Date
Afonso Jorge Ramos e328f3e9a1 fix(onboarding): keep the account submit disabled until the step advances 2026-08-20 18:16:00 +02:00
Thomas d241bac55d feat: improve assistant conversation experience (#6636) 2026-08-20 16:26:23 +02:00
Thomas 58a24569ce feat: expand utility widgets (#6638) 2026-08-20 16:26:04 +02:00
Thomas 53b518960c feat: retain application log history (#6633)
* feat: retain application log history

* fix: harden retained log streaming

* fix: harden contextual log focus

* fix: retain multiline log output
2026-08-20 12:57:30 +02:00
Thomas ddf8ba9948 feat: rework management settings layout (#6634) 2026-08-20 12:33:18 +02:00
Bergasha a2219b72f6 Merge pull request #6632 from homarr-labs/feat/v2-stocks-widget
feat (v2 stocks widget):Add a "Show details" toggle to the stock widget's compact view
2026-08-20 12:59:52 +10:00
Bergasha 7150cdbbab Add a "Show details" toggle to the stock widget's compact view
The compact view auto-reveals name/change/range as the widget gets
bigger, which can overlap badly depending on how the widget's actual
proportions land relative to those breakpoints. Add a switch (on by
default, matching current behavior) to force the minimal symbol+price
look regardless of size. Scoped to the compact layout only - the
Shift-triggered advanced view is untouched.
2026-08-20 12:32:42 +10:00
Thomas ff14613f95 feat: recalculate permission scope & permission matrix UI (#6612) 2026-08-19 17:22:31 +02:00
ajnart 78b3dc1922 fix: restore workshop create icon 2026-08-19 16:34:21 +02:00
ajnart 2e97b337d2 fix: remove universal create search 2026-08-19 16:29:30 +02:00
Thomas 369a2660b1 Embed integration editing in widget tab (#6630) 2026-08-19 14:01:11 +02:00
Bergasha 60aec370d6 Merge pull request #6627 from homarr-labs/fix/v2-media-server-widget
fix(v2 media widget): updated media server widget
2026-08-19 16:56:30 +10:00
Bergasha 6a76b9eafe Merge branch 'release/v2' into fix/v2-media-server-widget 2026-08-19 16:55:15 +10:00
Bergasha 76ba13fbad Address Codex review feedback on PR #6627
- Classify IPv4/IPv6 link-local addresses (169.254.0.0/16, fe80::/10) as
  LAN in Jellyfin location parsing - they can't traverse a router, so a
  false WAN reading was possible for common autoconfig/link-local sessions.
- Fix portrait video resolution mislabeling - a 1080x1920 clip was
  classified off its long-axis height (1920) and showed as 1440p. Classify
  by the short axis instead so orientation doesn't flip the result.
- Raise the compact-mode status column breakpoint from 420 to 540px - at
  420 the fixed-width user (200px) and status (190px) columns left almost
  no room for the currentlyPlaying column, the widget's primary content.
- Document the new resolution indicator in the media-server widget docs.
2026-08-19 16:45:09 +10:00
Thomas 150d38be62 feat(docker): add endpoint filtering and streamline setup assistant (#6625)
* feat(docker): add endpoint filtering and setup assistant

* fix(docker): simplify environment setup

* test(docker): cover demo endpoint filtering

* refactor(docker): use accordion for setup suggestions

* fix(docker): use docker icon for setup assistant

* refactor(docker): simplify assisted service setup

* refactor(docker): reuse shared translations

* fix(docker): address review feedback
2026-08-19 08:45:07 +02:00
Bergasha a4bbed0cb8 Fixed resolution showing the raw cropped height (e.g. 804p) instead of the nominal class Plex/Jellyfin display (1080p) - letterboxed/cinemascope sources are shorter than their resolution tier, so classify off the width-derived 16:9 height instead 2026-08-19 14:54:48 +10:00
Bergasha 90fa9e8741 Added tests for the LAN/WAN parsing and fixed IPv4-mapped IPv6 addresses being misclassified as WAN - a dual-stack server can report a LAN client as ::ffff:192.168.x.x, which the private-range check was missing since it only matched literal IPv6 ranges 2026-08-19 13:52:14 +10:00
Bergasha 4b20d6ecd0 Widened the status column from 140 to 190px - the Np resolution format is shorter so line 2 had room, but Transcoding video/audio badges are noticeably longer than Direct play and needed the space on line 1 2026-08-19 13:33:21 +10:00
Bergasha 6cac34e968 Changed resolution to show the actual streaming resolution (transcode target) instead of the source file's, formatted as e.g. 1080p instead of 1920x1080 2026-08-19 13:33:14 +10:00
Bergasha c6f77cec2e Added resolution to the status column, restructured into two lines: status + LAN/WAN on top, resolution + bitrate underneath 2026-08-19 13:33:08 +10:00
Bergasha acc97da84b Added LAN/WAN detection to Jellyfin, matching what Plex already reports - Jellyfin's sessions endpoint only gives a client IP (RemoteEndPoint), so classify it against the standard private IP ranges ourselves 2026-08-19 13:32:40 +10:00
Bergasha 6b10ac2ab5 Gave User a fixed 200px width so Currently playing (the busiest column) gets the largest share of the row instead of splitting it evenly with User 2026-08-19 13:32:34 +10:00
Bergasha c7563f2c39 Fixed Jellyfin bitrate showing null on direct play - the sessions endpoint only reliably fills in MediaSources bitrate while transcoding, so sum the individual media streams' own bitrates as a fallback 2026-08-19 13:32:28 +10:00
Bergasha 96676182f4 Gave the status column a fixed 140px width - it had no explicit width so it shared an equal, unconstrained share of the row with the other columns despite only holding a small badge 2026-08-19 13:32:21 +10:00
Bergasha 3769d88ebb Dimmed the column header labels and added a bottom border under the sticky header, so it reads as its own band instead of the same weight as the row data 2026-08-19 13:32:15 +10:00
Bergasha d9c35744f6 Only show a server's badge in the footer when it actually has an active session - selecting both Plex and Jellyfin always showed both badges even if only one was streaming. Also gave the footer badges a pill style and added a separator between the stream count and bitrate 2026-08-19 13:31:58 +10:00
Bergasha 33991f4e22 Added flexShrink to the session avatar so it stops getting squished in a tight row 2026-08-19 13:31:33 +10:00
Bergasha df300919fc Gated season/episode fields on episode type - was showing S/E labels on movies and music too, using the episode number as season 2026-08-19 13:31:27 +10:00
Thomas Camlong 315245e048 fix(media-server): close Stack element in session table cell 2026-08-18 10:28:44 +02:00
BergashaandThomas ebdfeb724b fix(media widget): text size fix and added season + Episode info (#6605)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-18 09:44:12 +02:00
Thomas e842177d9b feat: port release-v2 experience improvements from #6582 (audited subset) (#6609) 2026-08-18 09:40:02 +02:00
Thomas Camlong e3a713d2df Revert "fix: use stable selfhst icons for flaky integrations"
This reverts commit e7879ed3a2.
2026-08-18 09:24:00 +02:00
ajnart e7879ed3a2 fix: use stable selfhst icons for flaky integrations 2026-08-17 18:12:02 +02:00
ajnart 19e72fc757 fix: use stable readarr icon URL 2026-08-17 17:41:00 +02:00
Thomas Camlong 38fc5dd582 feat(custom-widgets): configure missing credentials 2026-08-17 15:47:42 +02:00
Thomas Camlong 6d284adb98 fix(media-server): stack user details reliably 2026-08-17 15:40:49 +02:00
Thomas Camlong bd171bf73b fix(custom-widgets): reduce action menu file below architecture line limit 2026-08-17 08:39:29 +02:00
Bergasha 1ee519bb7a fix(beszel): stop small text from vanishing under board zoom (#6616) 2026-08-17 07:14:29 +02:00
Thomas Camlong c53852fdb6 refactor(ui): adopt Mantine Marquee and drop mount guards
Rebuild the integration marquee on Mantine's Marquee, SimpleGrid and
ThemeIcon instead of the hand-rolled grid, track and keyframes, and
reduce the stylesheet to the mask and reduced-motion rule.

Drop the useMounted guards from the login form and let the buttons rely
on the pending state alone, and use Group for the restore flow's file
summary.
2026-08-17 01:15:45 +02:00
Thomas Camlong 206905ea2d feat(workshop): rebuild install and publish as pages
Replace the stacked install modals (browse -> details -> import review)
with page-based flows so the primary action is always reachable:

- /manage/custom-widgets/workshop browses submissions as a card grid
  with large previews, sitting in a tab next to the installed widgets
- /manage/custom-widgets/workshop/[id] merges the listing, security
  review and source setup onto one page with a sticky install footer
- /manage/custom-widgets/publish/[id] replaces the publish modal
- /manage/workshop redirects; its navbar entry is gone
- the Custom CSS picker collapses from three modals to one

Extract useCustomWidgetImport and ImportReviewContent so the Workshop
page and the file/clipboard dialog share one import pipeline.

Votes now show the viewer's own choice filled Reddit-style, backed by a
new user-votes query, and screenshots get a zoomable lightbox.

Rework the installed list onto ManageCollectionItem: show the widget
description, replace the cryptic "JSX - 2" badge with plain request and
origin facts, and swap the toggle and bare pencil for a status badge,
a labelled Edit button and a menu entry for enable/disable.

Fixes found while reviewing:

- importing could run twice and duplicate a widget; guard on pending and
  succeeded, and return to the installed list instead of the heavy edit
  form
- a background refetch of the submission (window focus, or the
  invalidation a vote triggers) reset the source setup and silently wiped
  part-typed credentials; key the reset on the sources, not the object
- breadcrumbs raised MISSING_MESSAGE for unmapped dynamic segments; fall
  back to the raw segment via t.has
- publishing had no in-flight guard and could post duplicate listings
- tooltips on disabled vote and report controls never fired, making the
  sign-in hint unreachable
2026-08-17 01:14:38 +02:00
Thomas Camlong 50537d077c feat(custom-widgets): export legacy definitions 2026-08-16 23:33:07 +02:00
Thomas Camlong 229a559aff fix: replace Workshop detail with import review 2026-08-16 23:16:29 +02:00
Thomas Camlong 4e514ab151 fix: show Workshop voting as net score 2026-08-16 23:15:09 +02:00
Thomas Camlong 960551f573 feat: simplify Workshop widget installation 2026-08-16 23:11:52 +02:00
Thomas Camlong 304ea089a2 fix: resolve backup migrations in development 2026-08-16 22:32:05 +02:00
Thomas Camlong 199f6ffc3b fix: run custom migrations on backup restore 2026-08-16 22:25:15 +02:00
Thomas Camlong 7e569bff4c fix: skip restore confirmation during onboarding 2026-08-16 22:10:12 +02:00
Thomas Camlong 0ec5f36050 ci: publish release v2 test image 2026-08-16 21:25:08 +02:00
416 changed files with 21997 additions and 4888 deletions

No files matched your search

+64 -33
View File
@@ -8,7 +8,7 @@ on:
types: [closed]
branches: ["**"]
push:
branches: [main, dev, beta, next, v2]
branches: [main, dev, beta, next, v2, release/v2]
merge_group:
workflow_dispatch:
inputs:
@@ -105,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"
@@ -131,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: .
@@ -147,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 }}
@@ -189,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
@@ -199,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: .
@@ -227,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
@@ -290,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
@@ -303,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"
@@ -340,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
View File
@@ -92,3 +92,6 @@ homarr-backup*.zip
*.sqlite-wal
*.sqlite-shm
/scripts/benchmarks/
# PR description screenshots belong on the pull request, not in the repository
.screenshots/
@@ -58,11 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker
| Environment Variable | Description | Possible values | Default |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
| 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
+88 -5
View File
@@ -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,9 +121,89 @@ services:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
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:
homarr:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./docker-ca.pem:/run/secrets/docker-ca.pem:ro
environment:
DOCKER_ENDPOINTS: >-
[
{
"id": "local",
"name": "Local Docker",
"kind": "docker",
"transport": { "type": "socket", "path": "/var/run/docker.sock" },
"capabilities": ["inventory", "logs", "lifecycle", "remove"]
},
{
"id": "remote-read-only",
"name": "Production inventory",
"kind": "docker",
"transport": {
"type": "tls",
"host": "docker.example.com",
"port": 2376,
"caPath": "/run/secrets/docker-ca.pem"
},
"capabilities": ["inventory", "logs"]
}
]
```
The stable `id` keeps containers from different endpoints distinct. `name` is shown in the interface, and `kind` can be `docker` or `podman`. Every endpoint must include the `inventory` capability; optionally add `logs`, `lifecycle`, and `remove`. Homarr hides or disables actions the endpoint does not allow and also enforces the same capabilities on the server.
Supported transports are:
- `socket`: an absolute in-container `path`
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
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 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:
- **Ready to connect** when its image or name matches a supported integration
- **New service** when it can be added as an app but no integration is recognized
- **Already represented** or **Linked** when an app or integration already matches
- **Address changed** when a service appears to match an integration but its reachable address has changed
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. **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.
:::
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.
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.
Each container row has an action menu with the following options:
- **View logs** - opens a dedicated logs page with a live terminal
@@ -148,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.
@@ -14,6 +14,8 @@ import { kubernetesIntegration } from ".";
Homarr provides a user-friendly Kubernetes dashboard that offers an overview of your cluster’s resources in a clear and structured interface. It allows users to view key Kubernetes objects, including `Pods`, `Services`, `Ingresses`, `Nodes`, `ConfigMaps`, `Secrets`, `Namespaces`, and `Volumes`, as well as basic metrics when the Kubernetes Metrics Server is enabled.
Docker service discovery does not convert Kubernetes resources into Homarr apps or integrations. Kubernetes remains a separate, read-only operational view, so a cluster resource is never changed or linked automatically.
:::warning[Read only]
This integration is read-only, meaning Homarr does not modify or manage cluster resources but serves as an informational tool to enhance visibility into your Kubernetes environment.
:::
@@ -25,7 +27,7 @@ This integration is read-only, meaning Homarr does not modify or manage cluster
Before deploying Homarr in Kubernetes, ensure the following prerequisites are met:
- Kubernetes (Version >= 1.24.0)
- Metrics Server ([Install from here](https://github.com/kubernetes-sigs/metrics-server))
- Metrics Server ([install from here](https://github.com/kubernetes-sigs/metrics-server)) is optional. Without it, Homarr still shows cluster inventory, node status, and reserved capacity, and identifies live CPU and memory usage as unavailable.
- RBAC Enabled (Required for Homarr Helm chart)
### Configuring RBAC in Helm Chart
@@ -45,6 +47,25 @@ Enabling RBAC in the Helm chart creates the necessary Kubernetes Role-Based Acce
- ClusterRole & ClusterRoleBinding: Extends the same permissions cluster-wide for additional flexibility.
:::
### Multiple Kubernetes contexts
When Homarr runs outside a cluster, set `KUBECONFIG` explicitly so Homarr loads the contexts from the referenced kubeconfig file or files. Mount the kubeconfig read-only and set the standard `KUBECONFIG` environment variable to its path inside the Homarr container:
```yaml
services:
homarr:
volumes:
- ./kubeconfig:/app/config/kubeconfig:ro
environment:
KUBECONFIG: /app/config/kubeconfig
```
The Kubernetes management pages include a context selector. The selected context is stored in the page URL, so each React Query cache entry and every API request remains context-specific. Resources with the same namespace and name in two contexts therefore remain separate.
Context status is checked independently. If one context is unavailable, the selector still lists healthy contexts. A context is shown as **Metrics unavailable** when its Kubernetes API is reachable but Metrics Server is not; inventory remains available without live CPU and memory usage.
When Homarr runs inside Kubernetes without `KUBECONFIG`, it keeps using the in-cluster service account as the single default context.
### Homarr Dashboard Overview
Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes:
+56
View File
@@ -72,6 +72,34 @@ API key authentication enables programmatic access for:
- Custom dashboards and monitoring tools
- Container orchestration systems that need to sync app definitions
## Permissions
API keys act as the user that created them, so every request is subject to that user's group
permissions. A call that the user is not allowed to make returns `FORBIDDEN`, and list endpoints
return only the records the user may see rather than failing.
### App endpoints require app management access
`GET /api/apps`, `GET /api/apps/paginated` and `GET /api/apps/search` (tRPC `app.all`,
`app.getPaginated`, `app.search`) require the **modify all apps** permission. They previously
returned the full app catalogue — including internal URLs — to any authenticated user.
If a script that used to work now returns `FORBIDDEN`, grant **modify all apps** to a group the key
owner belongs to, or switch to `GET /api/apps/selectable`, which returns the reduced fields used by
board pickers.
### Integration endpoints are filtered per key
`integration.all`, `integration.allThatSupportSearch`, `integration.allOfGivenCategory` and
`integration.search` filter server side. Without a global integration permission, a key only
receives the integrations its owner has been granted access to, so responses may contain fewer
records than before rather than returning an error. `integration.search` applies its `limit` after
filtering, so a scoped key still receives a full page of results.
Each returned integration carries a `permissions` object — check `hasUseAccess` before reading data
and `hasInteractAccess` before performing actions; `false` means the key owner lacks that level for
that integration, not that the call failed.
## Board Automation
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
@@ -86,6 +114,34 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom`
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
## Docker action payload migration
Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a
breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action.
Replace the old payload:
```json
{ "ids": ["container-id"] }
```
with endpoint-qualified targets:
```json
{
"targets": [{ "endpointId": "local", "id": "container-id" }]
}
```
Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action.
Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
Docker or Podman endpoints from targeting the wrong service.
`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
+13 -2
View File
@@ -51,6 +51,17 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
## List of apps
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app
directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
### Accessing the page
The `/manage/apps` page is reachable when you can manage all apps (`app-modify-all`) or create apps (`app-create`):
- **Manage all (`app-modify-all`)**: the full list of apps is shown and searchable, and each row can be edited.
- **Create only (`app-create`)**: the page shows an empty "create an app" state instead of the list, so the names and
URLs of existing apps are never disclosed. The create action is still available.
- **Neither**: the page is not reachable and returns a not-found page.
Creating an app requires `app-create`, editing requires `app-modify-all`, and deleting requires `app-full-all`;
board permissions are not required.
+4 -1
View File
@@ -44,12 +44,15 @@ host gateway address.
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
Use **Conversations** to search, switch, rename, export, or delete threads.
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
summary of accessible Homarr resources. Live status is fetched only when needed.
## Permissions and privacy
- Read-only actions can run automatically. Changes require approval.
- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the
current conversation, turn off when you switch conversations, and never answer questions for you.
- Homarr permissions apply to every tool call and mentioned item.
- API keys and custom headers are encrypted and never returned to the browser.
- Conversations are stored per user and can be exported or deleted.
+14 -1
View File
@@ -204,6 +204,15 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
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 lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
destinations before the widget is created, so the common flow does not require a second move step.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
saved integration changes apply everywhere that connection is used.
Pointer users can drag 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.
@@ -218,7 +227,11 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
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%" }} />
<img
src={keyboardTileEditingImage}
alt="A focused board tile in keyboard editing mode"
style={{ maxWidth: "100%" }}
/>
#### Preview
@@ -17,6 +17,24 @@ To create an integration, choose **New integration** and select the supported ap
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
### Accessing the page
The `/manage/integrations` page is reachable when at least one of the following applies:
- you can manage all integrations (global `integration-full-all`),
- you can create integrations (`integration-create`), or
- you have been granted full access to at least one specific integration.
What the page shows depends on the case:
- **Manage all (`integration-full-all`)**: the full list of integrations is shown.
- **Create only (`integration-create`)**: the page shows an empty "create an integration" state instead of the list, with the create action still available.
- **Delegated (full access on one or more)**: only the integrations you have full access to are listed, above a note making clear that the view is scoped to what you can manage.
- **None**: the page is not reachable and returns a not-found page.
Where you have a mix of access, the list combines the integrations you can manage. Integration lists are filtered
server side, so a user never receives data about integrations they lack access to.
Next, you'll need to fill out the required fields to configure the integration:
![](./img/form-create-integration.png)
@@ -59,6 +77,22 @@ It is not allowed to create an integration without passing the test.
This is to ensure that you minimize the amount of broken applications on your Homarr instance.
The test and create button will perform a network request to the application and authenticate if required.
When you connect a service from a board, Homarr keeps you in the same setup flow. After the connection succeeds, it
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
widget returns to its settings with the new integration already selected.
Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
Successful changes apply to every app and widget that uses the integration.
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
create one.
When the connection unlocks useful widgets that are not on the current board yet, the confirmation also shows up to
three explainable recommendations. A recommendation is based only on the services you can use and the widget kinds
already present on that board. You can open the existing widget picker prefiltered to the recommendation, or dismiss
recommendations for that service on that board.
## Managing permissions
On the edit page of an integration, you can manage which users / groups can
+34
View File
@@ -0,0 +1,34 @@
---
tags:
- Administration
- Logs
- Troubleshooting
---
# Logs
The logs page shows recent Homarr application logs and continues updating as new events occur. Use it to investigate
widget, integration, and server errors without opening the container logs separately.
## Opening the logs page
Navigate to **Management** > **Tools** > **Logs**. The page and widget log links are only available to users with the
**View logs** permission.
When a widget reports an error, **Check logs for more details** opens the logs page in a new tab and marks the time when
the widget displayed the error. The related server error normally appears immediately above that marker. If those logs
have already been removed from the retained history, the marker explains that they are no longer available.
## Retained and live logs
Homarr retains the latest 5,000 log events in Redis. Opening the page first loads this history and then continues with
live events without a gap. The history is bounded, so older events are removed automatically.
The history survives normal application restarts when the configured Redis instance persists its data. For complete
platform-level history, startup failures, or events older than the retained limit, use the logs provided by Docker or
your deployment platform.
## Display controls
The severity selector applies to both retained and live events. Selecting a different level redraws the existing view
using that threshold. The font-size control changes the terminal text size and is remembered in the browser.
+1 -1
View File
@@ -13,7 +13,7 @@ tags:
The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
All settings sections remain visible in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
## Analytics
+70 -12
View File
@@ -163,19 +163,77 @@ Members can be removed from the group using the remove button at the end:
#### Permissions tab
In this tab, you manage what permissions the members of this group will have.
Currently, the following permissions are available:
Permissions are shown as a matrix with one row per category (boards, apps, integrations, and so on). Each row has a
segmented control that lets you pick a single **access level**, plus a checkbox for each **capability** the category
has.
- Boards
- **Create boards**: Allow members to create boards
- **View all boards**: Allow members to view all boards
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone)
- Integrations
- **Create integrations**: Allow members to create integrations
- **Use all integrations**: Allows members to add any integrations to their boards
- **Interact with any integration**: Allow members to interact with any integration
- **Full integration access**: Allow members to manage, use and interact with any integration
- **Administrator**: Members with this permission have full access to all features and settings
Levels in a category are cumulative: selecting a level grants that level and every level below it in the same
category. Choosing **No access** grants no access level for that category.
##### Capabilities are separate from the access level
A capability is something you can grant on its own, independently of how much of everyone else's data the members
can see. Creating is the usual example: you can let members create their own boards without letting them view or
modify anyone else's boards. Apps additionally have **use all apps**, which lets members put any app on a board
without being able to edit apps.
Some access levels already include a capability through permission inheritance (marked in the table below); the
checkbox is then ticked, disabled, and explains which level includes it.
##### Administration
Selecting the **Administrator** level grants full access to everything. While it is selected, every other category
is disabled and explains that administrators already have that access. Setting the administrator row back to
**No access** re-enables the other categories with the selections they had before.
##### Access levels
The access levels below are cumulative, in ascending order, with the category's capabilities listed separately:
| Category | Access levels (ascending) | Capabilities | Already included at |
| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------- |
| Boards | view all boards &rarr; modify all boards &rarr; full board access | create boards | full board access |
| Apps | modify all apps &rarr; full app access | use all apps, create apps | create apps: modify all apps and full app access; use all apps: full app access |
| Integrations | use all integrations &rarr; interact with any integration &rarr; full integration access | create integrations | full integration access |
| Search engines | modify all search engines &rarr; full search engine access | create search engines | modify all search engines, full search engine access |
| Medias | view all medias &rarr; full media access | upload medias | full media access |
| Other | view logs | — | — |
| Administrator | administrator | — | — |
Apps are the one category where "use" is not a level. Being able to modify every app does **not** imply being able
to put any app on a board — only full app access grants both — so the two are kept independent rather than stacked,
and picking **Modify** never silently grants use of every app.
The levels grant the following:
- **Boards**: view all boards; modify all boards (not access control and danger zone); full board access (view, modify and delete every board, including access control and the danger zone).
- **Apps**: modify all apps; full app access (manage, use and delete any app).
- **Integrations**: use all integrations; interact with any integration; full integration access (manage, use and interact with any integration).
- **Search engines**: modify all search engines; full search engine access (manage and delete any search engine).
- **Medias**: view all medias; full media access (manage and delete any media).
- **Other**: view logs.
##### Presets
Above the rows you can apply a preset to fill in a common configuration in one click:
- **Viewer**: read-only - view all boards and medias, use any app on a board, and use existing integrations.
- **Editor**: day-to-day management - modify all boards and apps, create boards and integrations, interact with any integration, modify search engines, and upload medias, without deleting resources or managing access.
- **Admin**: grants the administrator permission (full access to everything).
When the current selection does not match any preset, a **Custom** badge is shown.
##### Also grants
Some levels include additional permissions automatically. When a level implies extra permissions (for example,
modifying all apps also allows creating apps), a dimmed **Also grants: …** hint is shown below the category row,
listing the implied permissions.
##### Effective permissions
Below the rows, the **Effective permissions** panel can be expanded to review everything members of this group will
be able to do - the full, de-duplicated set of permissions, including everything granted indirectly through the
levels - grouped by category, with a count for each.
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
@@ -0,0 +1,47 @@
---
title: "Air Quality & UV"
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { airQualityWidget } from ".";
<WidgetHeader widget={airQualityWidget} categories={["Health", "Weather"]} />
The Air Quality & UV widget shows the current air quality index for a selected location. It adds the UV index,
dominant pollutant, particulate matter and the highest available pollen reading as space allows. Open the advanced
view for a 24-hour AQI and UV chart, all available pollutant and pollen readings, and seven-day daily maxima.
Select the European or US AQI scale, or use the automatic setting. Automatic uses the US scale for viewers whose
locale region is the United States and the European scale everywhere else. The selected scale stays in widget options
instead of being repeated beside every reading. Pollen values are individual measured concentrations; the widget does
not combine them into a single pollen index.
The categories and colors summarize published index bands and are not medical advice. Pollutant concentrations use
micrograms per cubic meter (`µg/m³`), and pollen uses grains per cubic meter.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={airQualityWidget.configuration} />
### Data source
Homarr uses the free Open-Meteo Air Quality API. The widget refreshes active data every 15 minutes and requests a
seven-day forecast for European AQI, US AQI, UV, particulate matter, ozone, nitrogen dioxide, sulphur dioxide, carbon
monoxide, and available alder, birch, grass, mugwort, olive and ragweed pollen. Identical location requests reuse the
server response for 15 minutes. If a refresh fails, Homarr can keep a recent successful response visible for up to 24
hours instead of replacing it with an empty state.
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. If you need to allowlist the
service, use `https://air-quality-api.open-meteo.com`.
@@ -0,0 +1,43 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconWind } from "@tabler/icons-react";
export const airQualityWidget: WidgetDefinition = {
icon: IconWind,
name: "Air Quality & UV",
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
path: "../../widgets/air-quality",
configuration: {
items: [
{
name: "Air quality location",
description: "Location used for air quality, UV and pollen data",
values: "Select a location through search or longitude/latitude",
defaultValue: "Paris / 48.85341, 2.3488",
},
{
name: "AQI standard",
description: "Choose the European or US air quality scale, or select one from the viewer's locale",
values: { type: "select", options: ["Automatic for locale", "European AQI", "US AQI"] },
defaultValue: "Automatic for locale",
},
{
name: "Show UV index",
description: "Display the current UV index and its forecast",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show pollutant concentrations",
description: "Display particulate matter and gaseous pollutant concentrations",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show pollen concentrations",
description: "Display available pollen concentrations",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+21 -1
View File
@@ -15,6 +15,26 @@ import { clockWidget } from ".";
The clock widget displays the current date and time in a customizable format.
Advanced view turns it into a World Clock. The configured primary timezone stays prominent while New York, Paris, and
Tokyo are included as secondary defaults. You can add, rename, remove, and reorder up to six cities in widget options;
London, Sydney, and UTC are also available as quick presets.
The digital advanced view keeps the primary timezone prominent and presents every secondary city as a concise,
comparable card. The primary clock includes a segmented 24-hour rail with a current-time marker, elapsed and remaining
time, six-hour ticks, and a legend for night, dawn, day, and dusk. These phases are based on local clock time and are not
astronomical sunrise or sunset calculations. Secondary cards stop after their localized date and timezone offsets.
The compact widget remains a single numeric clock. The time-format selector shows common Day.js presets with live
examples and applies to the primary clock. Choosing a format with seconds enables second-level updates; 12-hour and
24-hour display are selected through those same formats. The date-format selector likewise
shows live Day.js examples and their tokens. Secondary cities always include a short localized date and a
Yesterday, Today, or Tomorrow label relative to the primary timezone. UTC and relative offsets follow current timezone
rules, including daylight-saving changes.
Weather remains optional and uses its own configured location rather than a World Clock city. Its icon is static by
default and can be animated explicitly; reduced-motion preferences always disable animation. An unsupported saved
timezone is identified without preventing valid clocks from rendering.
### Screenshots
import localClock from "./img/local.png";
@@ -27,7 +47,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
</div>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
+35 -22
View File
@@ -20,18 +20,6 @@ export const clockWidget: WidgetDefinition = {
values: { type: "string" },
defaultValue: "-",
},
{
name: "24-hour format",
description: "Use 24-hour format instead of 12-hour format.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Display seconds",
description: "Whether to show seconds in the clock.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Use fixed timezone",
description: "Select custom timezone to display instead of the client timezone.",
@@ -52,21 +40,46 @@ export const clockWidget: WidgetDefinition = {
},
{
name: "Date format",
description: "Format of the date displayed below the clock.",
values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
description: "Choose a common Day.js date format with a live example and its tokens.",
values: "YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, or common long and short date formats",
defaultValue: "dddd, MMMM D",
},
{
name: "Custom time format",
description: "Format of the time displayed in the clock.",
values: 'ISO 8601 format, e.g. "HH:mm:ss", "hh:mm A", etc.',
defaultValue: "-",
name: "Time format",
description:
"Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.",
values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A",
defaultValue: "HH:mm",
},
{
name: "Custom date format",
description: "Format of the date displayed below the clock.",
values: 'ISO 8601 format, e.g. "YYYY-MM-DD", "DD/MM/YYYY", etc.',
defaultValue: "-",
name: "Show weather",
description: "Show a compact weather summary from an independently configured location.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Weather location",
description: 'Location used by the weather summary. Only shown if "Show weather" is enabled.',
values: "A searched city or custom latitude and longitude",
defaultValue: "Brisbane",
},
{
name: "Use Fahrenheit",
description: 'Use Fahrenheit in the weather summary. Only shown if "Show weather" is enabled.',
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Animate weather icon",
description: "Animate the weather icon unless the device requests reduced motion.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "World clock cities",
description: "Add, label, and order up to six IANA timezones for advanced view.",
values: "Up to six labeled IANA timezones",
defaultValue: "New York, Paris, and Tokyo",
},
],
},
@@ -0,0 +1,44 @@
---
title: "Countdowns"
description: "Tracks upcoming events and recurring anniversaries."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { countdownWidget } from ".";
<WidgetHeader widget={countdownWidget} categories={["Time"]} />
The Countdowns widget tracks up to 20 events without an integration. Add each event in widget options, choose its local
date and time, and select an IANA timezone. Events can be reordered by dragging them.
The compact widget shows the first configured event, so dragging an event to the top selects the countdown displayed on
the board. Advanced view uses that same event for its larger summary and preserves the configured order within its
upcoming and completed groups. List cards keep their occurrence date at the bottom, below the optional progress bar.
One-time events remain completed after their target. A yearly event keeps the same local date and wall-clock time in its
configured timezone, including when daylight-saving offsets change. A February 29 event waits until the next leap year.
If a configured local time falls in a daylight-saving gap, the first valid time afterward is used; if it occurs twice,
the earlier occurrence is used.
Progress is optional. One-time events need a start date before a percentage can be calculated. Yearly events show
progress from the previous occurrence to the next. When enabled, seconds remain visible for every upcoming event.
Browser notifications are an optional local preference in advanced view and are disabled by default. Permission is
requested only after you enable notifications, and alerts can fire once per occurrence while Homarr is open. Browser
storage uses the board and widget identifiers to keep preferences separate and prevent duplicate alerts; clearing site
data resets them. Widget previews do not persist alert preferences.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={countdownWidget.configuration} />
+31
View File
@@ -0,0 +1,31 @@
import { WidgetDefinition } from "@site/src/types";
import { IconHourglass } from "@tabler/icons-react";
export const countdownWidget: WidgetDefinition = {
icon: IconHourglass,
name: "Countdowns",
description: "Tracks upcoming events and recurring anniversaries.",
path: "../../widgets/countdown",
configuration: {
items: [
{
name: "Events",
description: "Add, edit, and order one-time or yearly events with a date, time, and IANA timezone.",
values: "Up to 20 events",
defaultValue: "No events",
},
{
name: "Show progress",
description: "Show progress from an optional start date, or between yearly occurrences.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show seconds",
description: "Keep seconds visible for every upcoming event, including events more than a day away.",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
@@ -7,20 +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 { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
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
@@ -29,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.
@@ -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",
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
+54
View File
@@ -0,0 +1,54 @@
---
title: "Timer / Pomodoro"
description: "Runs a simple timer or a configurable Pomodoro sequence."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { timerWidget } from ".";
<WidgetHeader widget={timerWidget} categories={["Productivity", "Time"]} />
The Timer / Pomodoro widget runs entirely in your browser and does not require an integration. The compact widget
always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening
the board or returning to a background tab restores the correct remaining time instead of restarting the countdown.
Timer mode uses one configured duration. Pomodoro mode starts with a focus session, follows most completed focus
sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and
focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are
reconciled from their original deadlines after the board returns to the foreground.
Advanced view shows the current sequence, configured durations, and local alert preferences. Browser notifications are
requested only when enabled. Notification and sound preferences, live progress, deadlines, and cycle position are
stored locally using the board and widget identifiers. They are separate for each browser or device, are available to
guest viewers, and are removed by clearing site data. Widget previews are ephemeral and do not write local timer state.
Timer sounds are enabled by default and use the same low-volume Tiks sound system as onboarding: start and pause use
rising and falling soft-theme cues, reset uses a short click, and phase completion uses the glass theme's resonant
notification ding. You can disable them in advanced view. The cues respect the device's reduced-motion preference and
supplement the visible timer state rather than replacing it.
When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the
board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at
300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border.
The current phase stays pinned to the compact widget's top-left corner and wraps when needed. Timer text and controls
use the dashboard theme's responsive size scale, with secondary details hidden when the widget preview is narrow or
short.
Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them.
The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited
from the widget context menu.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={timerWidget.configuration} />
+61
View File
@@ -0,0 +1,61 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconClockPlay } from "@tabler/icons-react";
export const timerWidget: WidgetDefinition = {
icon: IconClockPlay,
name: "Timer / Pomodoro",
description: "Runs a simple timer or a configurable Pomodoro sequence.",
path: "../../widgets/timer",
configuration: {
items: [
{
name: "Mode",
description: "Run one reusable timer or a repeating focus and break sequence",
values: { type: "select", options: ["Timer", "Pomodoro"] },
defaultValue: "Pomodoro",
},
{
name: "Timer minutes",
description: "Duration of the reusable timer",
values: "1-1440 minutes",
defaultValue: "10",
},
{
name: "Focus minutes",
description: "Duration of each Pomodoro focus session",
values: "1-180 minutes",
defaultValue: "25",
},
{
name: "Short break minutes",
description: "Duration of breaks between most focus sessions",
values: "1-60 minutes",
defaultValue: "5",
},
{
name: "Long break minutes",
description: "Duration of the break at the end of a Pomodoro cycle",
values: "1-180 minutes",
defaultValue: "15",
},
{
name: "Focus sessions before a long break",
description: "Number of completed focus sessions in one Pomodoro cycle",
values: "1-12 sessions",
defaultValue: "4",
},
{
name: "Automatically start breaks",
description: "Start the next short or long break when a focus session completes",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Automatically start focus sessions",
description: "Start the next focus session when a break completes",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
+34
View File
@@ -0,0 +1,34 @@
---
title: "Today"
description: "Displays the current date and progress through the calendar."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { todayWidget } from ".";
<WidgetHeader widget={todayWidget} categories={["Date", "Time"]} />
The Today widget presents the current local date without duplicating a live clock. It progressively adds the week
number, day of year, quarter, remaining days, and calendar progress as more space becomes available.
The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow
your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count
unambiguous.
The widget runs entirely in your browser and does not send date or location data to an external service. It follows
the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={todayWidget.configuration} />
+43
View File
@@ -0,0 +1,43 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconCalendarWeek } from "@tabler/icons-react";
export const todayWidget: WidgetDefinition = {
icon: IconCalendarWeek,
name: "Today",
description: "Displays the current date and progress through the calendar.",
path: "../../widgets/today",
configuration: {
items: [
{
name: "Week numbering",
description: "Use locale-specific week numbering or ISO 8601 week numbers",
values: { type: "select", options: ["Use locale rules", "ISO 8601"] },
defaultValue: "Use locale rules",
},
{
name: "Show week number",
description: "Display the current week number when the widget has room",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show day of year",
description: "Display the current day number within the year",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show quarter",
description: "Display the current calendar quarter",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show year progress",
description: "Display progress through the current year",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+18 -15
View File
@@ -13,23 +13,21 @@ import { weatherWidget } from ".";
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
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 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.
The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start
with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and
temperature readable before removing secondary details or forecast days.
### Screenshots
Select a forecast day to inspect precipitation, apparent temperature, humidity, wind, UV index, sunrise, sunset and
daylight duration with a mouse, keyboard or touch. Open the advanced view for a current-condition summary, a 24-hour
temperature and precipitation chart, and the complete seven-day forecast. The chart has an equivalent data table for
assistive technologies.
import forecastWeather from "./img/forecast.png";
import currentWeather from "./img/current.png";
import advancedWeather from "./img/advanced-view.png";
The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly
details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on
the standard desktop surface and fewer columns as the surface narrows.
<div className="flex flex-col gap-1">
<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>
Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's
reduced-motion preference always disables it.
<WidgetInteractionGuide />
@@ -43,5 +41,10 @@ import advancedWeather from "./img/advanced-view.png";
### Data source
We use the Open Meteo API to fetch the weather and locations.
We use the Open Meteo API to fetch the weather and locations. Active weather data refreshes every ten minutes, while
identical location requests can reuse a server response for five minutes. A manual refresh may therefore return the
same still-current server response. If a refresh fails, Homarr keeps the last successful data visible and shows an
error indicator.
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests.
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
+19 -3
View File
@@ -28,8 +28,24 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "no",
},
{
name: "Show humidity",
description: "Display humidity in current conditions, hourly details, and daily forecasts",
values: {
type: "boolean",
},
defaultValue: "yes",
},
{
name: "Animate weather icons",
description: "Animate condition icons unless the operating system requests reduced motion",
values: {
type: "boolean",
},
defaultValue: "no",
},
{
name: "Use imperial speed units",
description: "Display wind speed in mph instead of km/h",
@@ -59,7 +75,7 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "no",
defaultValue: "yes",
},
{
name: "Has forecast",
@@ -67,13 +83,13 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "no",
defaultValue: "yes",
},
{
name: "Amount of forecast days",
description: "Number of days to show in the forecast",
values: "1-7",
defaultValue: "5",
defaultValue: "3",
},
],
},
@@ -1,4 +1,4 @@
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
@@ -6,21 +6,27 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
return (
<WidgetCard width={1} className={clsx("gap-2", className)}>
<div className="flex gap-2 justify-center items-center">
<IconCloud size={20} />
<span className="text-xl font-bold">10.8°C</span>
</div>
<div className="flex gap-2 justify-center items-center">
<IconWind size={14} />
<span className="text-sm">5 km/h</span>
</div>
<div className="flex gap-1 justify-center items-center">
<div className="flex justify-center items-center">
<IconArrowUpRight size={14} />
<span className="text-sm">13.5°C</span>
<IconSun size={22} />
<div className="flex flex-col leading-none">
<span className="text-xl font-bold">18.4°C</span>
<span className="text-xs text-muted-foreground">Paris</span>
</div>
<div className="flex justify-center items-center">
<IconArrowDownRight size={14} />
<span className="text-sm">6.4°C</span>
</div>
<div className="flex justify-evenly items-center w-full">
<div className="flex flex-col items-center text-xs">
<span>Mon</span>
<IconSun size={16} />
<span>21°</span>
</div>
<div className="flex flex-col items-center text-xs">
<span>Tue</span>
<IconCloud size={16} />
<span>19°</span>
</div>
<div className="flex flex-col items-center text-xs">
<span>Wed</span>
<IconCloudRain size={16} />
<span>16°</span>
</div>
</div>
</WidgetCard>
+11 -1
View File
@@ -1266,9 +1266,15 @@
"properties": {
"boardId": {
"type": "string"
},
"name": {
"type": "string"
},
"layoutId": {
"type": "string"
}
},
"required": ["boardId"],
"required": ["boardId", "name", "layoutId"],
"additionalProperties": false
}
}
@@ -2100,6 +2106,10 @@
"enum": [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
"app",
"iframe",
"video",
+1
View File
@@ -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:",
@@ -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: {
@@ -140,7 +139,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
{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} />
@@ -148,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>
)}
@@ -159,13 +158,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
)}
{providers.includes("oidc") && (
<Button
type="button"
fullWidth
variant="light"
disabled={!isMounted || isPending}
onClick={() => void signInAsync("oidc")}
>
<Button type="button" fullWidth variant="light" disabled={isPending} onClick={() => void signInAsync("oidc")}>
{t("action.login.labelWith", { provider: oidcClientName })}
</Button>
)}
@@ -175,13 +168,12 @@ 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 (
@@ -192,7 +184,7 @@ const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsW
fullWidth
onClick={() => form.setFieldValue("provider", provider)}
loading={isPending && isCurrentProviderActive}
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
disabled={isPending && !isCurrentProviderActive}
>
{children}
</Button>
@@ -8,6 +8,8 @@ import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
@@ -64,6 +66,8 @@ export const ClientBoard = () => {
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
@@ -1,18 +1,15 @@
"use client";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -20,60 +17,41 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const { openModal } = useModalAction(UniversalCreateModal);
const t = useI18n();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const handledInitialAdd = useRef(false);
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
useEffect(() => {
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
handledInitialAdd.current = true;
addWidget();
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({});
};
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("add");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [addWidget, board.id, pathname, router, searchParams]);
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<HeaderButton
aria-label={t("board.action.addContent")}
onClick={() =>
openModal({
boardActions: {
widget: addWidget,
app: addApp,
integration: canConnectService ? connectService : undefined,
container: addContainer,
},
})
}
>
<IconPlus stroke={1.5} />
</HeaderButton>
);
};
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconDeviceFloppy,
IconLayoutBoard,
@@ -98,6 +99,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onMutate(boardToSave) {
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
},
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
@@ -0,0 +1,35 @@
import type { Session } from "@homarr/auth";
import { api } from "@homarr/api/server";
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
/**
* Session-aware wrappers around the shared management access rules in `@homarr/definitions`, so the
* navigation and the pages resolve access exactly the same way the API does.
*/
export const getAppsSectionAccess = (session: Session | null) =>
getAppManagementAccess(session?.user.permissions ?? []);
/**
* Pure variant for callers that already fetched the integrations the user can see, so the list is
* only queried once per request.
*/
export const getIntegrationsSectionAccess = (
session: Session | null,
visibleIntegrations: { permissions: { hasFullAccess: boolean } }[],
) =>
getIntegrationManagementAccess(
session?.user.permissions ?? [],
visibleIntegrations.some((integration) => integration.permissions.hasFullAccess),
);
export const getIntegrationsSectionAccessAsync = async (session: Session | null) => {
const permissions = session?.user.permissions ?? [];
// Only pay for the lookup when the answer is not already known from the global permissions.
const needsDelegatedLookup =
session !== null && !permissions.includes("integration-full-all") && !permissions.includes("integration-create");
return getIntegrationsSectionAccess(session, needsDelegatedLookup ? await api.integration.all() : []);
};
@@ -1,5 +1,5 @@
import { Fragment } from "react";
import { redirect } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -16,6 +16,7 @@ import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { getAppsSectionAccess } from "../_access";
import { AppDeleteButton } from "./_app-delete-button";
const searchParamsSchema = z.object({
@@ -41,16 +42,27 @@ interface AppsPageProps {
export default async function AppsPage(props: AppsPageProps) {
const session = await auth();
if (!session) redirect("/auth/login");
const { canManageAll, canCreate, canAccess } = getAppsSectionAccess(session);
if (!canAccess) notFound();
const canDelete = session.user.permissions.includes("app-full-all");
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
// Without app-modify-all the user may add apps but not browse the ones they cannot manage,
// so the list stays empty instead of leaking every app name and URL.
const { items: apps, totalCount } = canManageAll
? await api.app.getPaginated(searchParams)
: { items: [], totalCount: 0 };
const t = await getScopedI18n("app");
const canCreate = session.user.permissions.includes("app-create");
const canModify = session.user.permissions.includes("app-modify-all");
const canDelete = session.user.permissions.includes("app-full-all");
const hasSearch = Boolean(searchParams.search?.trim());
const hasSearch = canManageAll && Boolean(searchParams.search?.trim());
const emptyState = hasSearch ? (
const emptyState = !canManageAll ? (
<NoResults
icon={IconBox}
title={t("page.list.noResults.createOnlyTitle")}
description={t("page.list.noResults.createOnlyDescription")}
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new" }}
/>
) : hasSearch ? (
<NoResults
icon={IconBox}
title={t("page.list.noResults.filteredTitle")}
@@ -82,12 +94,14 @@ export default async function AppsPage(props: AppsPageProps) {
) : undefined
}
toolbar={
<SearchInput
placeholder={`${t("search")}...`}
ariaLabel={t("search")}
defaultValue={searchParams.search}
flexExpand
/>
canManageAll ? (
<SearchInput
placeholder={`${t("search")}...`}
ariaLabel={t("search")}
defaultValue={searchParams.search}
flexExpand
/>
) : undefined
}
footer={
totalCount > searchParams.pageSize ? (
@@ -100,7 +114,6 @@ export default async function AppsPage(props: AppsPageProps) {
<AppItem
key={app.id}
app={app}
canModify={canModify}
canDelete={canDelete}
editLabel={t("page.list.action.edit", { name: app.name })}
/>
@@ -113,12 +126,11 @@ export default async function AppsPage(props: AppsPageProps) {
interface AppItemProps {
app: RouterOutputs["app"]["getPaginated"]["items"][number];
canModify: boolean;
canDelete: boolean;
editLabel: string;
}
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
const descriptionLines = app.description?.split("\n");
const safeHref = getSafeAppHref(app.href);
@@ -157,23 +169,19 @@ const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
) : undefined
}
actions={
canModify || canDelete ? (
<ActionIconGroup>
{canModify && (
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={editLabel}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
)}
{canDelete && <AppDeleteButton app={app} />}
</ActionIconGroup>
) : undefined
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={editLabel}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
{canDelete && <AppDeleteButton app={app} />}
</ActionIconGroup>
}
/>
);
@@ -54,7 +54,12 @@ export default async function ManageBoardsPage() {
floatingPrimaryAction={canCreateBoards}
>
{boards.length === 0 && (
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
<NoResults
icon={IconLayoutDashboard}
title={t("noResults.title")}
description={t("noResults.description")}
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
/>
)}
{boards.length > 0 && (
<TourTarget id="manage-boards-list">
@@ -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
@@ -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>
);
}
@@ -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>
);
};
@@ -10,9 +10,9 @@ import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
import { CustomWidgetList } from "./_custom-widget-list";
import { CustomWidgetTabs } from "./_custom-widget-tabs";
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
export default async function CustomWidgetsPage() {
const session = await auth();
@@ -38,13 +38,13 @@ export default async function CustomWidgetsPage() {
>
{t("action.downloadSkill")}
</MobileAffixButton>
<WorkshopInstallButton />
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
</MobileAffixButton>
</Group>
}
toolbar={<CustomWidgetTabs active="installed" />}
>
<CustomWidgetBetaBanner />
<CustomWidgetList definitions={definitions} />
@@ -0,0 +1,248 @@
"use client";
import { useEffect, useState } from "react";
import {
Alert,
Box,
Button,
Checkbox,
FileInput,
Group,
Paper,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import {
IconArrowLeft,
IconBuildingStore,
IconCheck,
IconExternalLink,
IconPhoto,
IconShieldCheck,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import {
getPrivateWorkshopSourceNames,
publishWorkshopDefinition,
serializeWorkshopDefinition,
} from "~/components/workshop/workshop-publish-definition";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
const listHref = "/manage/custom-widgets";
export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) {
const t = useScopedI18n("workshop");
const session = useWorkshopSession();
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [publishedSubmissionId, setPublishedSubmissionId] = useState<string | null>(null);
const createSubmission = useWorkshopCreateMutation(session.client);
const definition = clientApi.customWidget.export.useQuery({ id: widget.id });
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
const breadcrumb = <DynamicBreadcrumb dynamicMappings={new Map([[widget.id, widget.name]])} />;
const publish = async () => {
// Publishing is immediate and public; a second in-flight call would create a
// duplicate listing.
if (createSubmission.isPending) return;
setError(null);
try {
if (!definition.data) throw new Error(t("publish.error"));
if (!workshopScreenshotsSchema.safeParse(screenshots).success) throw new Error(t("publish.invalidScreenshot"));
let submissionId: string | null = null;
const result = await publishWorkshopDefinition({
inspectedDefinition: definition.data,
refetchDefinition: async () => (await definition.refetch()).data,
publish: async (content) => {
const submission = await createSubmission.mutateAsync({
input: { type: "customWidget", title, description, content },
screenshots,
});
submissionId = submission.id;
},
});
if (result === "unavailable") throw new Error(t("publish.error"));
if (result === "changed") {
setSourceUrlsReviewed(false);
setError(t("publish.definitionChanged"));
return;
}
if (!submissionId) throw new Error(t("publish.error"));
setPublishedSubmissionId(submissionId);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
}
};
if (publishedSubmissionId) {
return (
<ManagePageLayout title={t("publish.successTitle")} breadcrumb={breadcrumb}>
<Paper withBorder radius="md" p="xl">
<Stack align="center" gap="lg">
<ThemeIcon size={64} radius="xl" color="green" variant="light">
<IconCheck size={32} stroke={2} />
</ThemeIcon>
<Text c="dimmed" ta="center" maw={520}>
{t("publish.success")}
</Text>
<Text c="dimmed" size="sm" ta="center" maw={520}>
{t("publish.manageDescription")}
</Text>
<Group gap="sm">
<Button component={Link} href={listHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
{t("publish.done")}
</Button>
<Button
component="a"
href={getWorkshopWebUrl(publishedSubmissionId)}
target="_blank"
rel="noopener noreferrer"
rightSection={<IconExternalLink size={16} />}
>
{t("publish.viewSubmission")}
</Button>
</Group>
</Stack>
</Paper>
</ManagePageLayout>
);
}
const blocked =
!session.user ||
createSubmission.isPending ||
!definition.data ||
definition.isError ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed);
return (
<ManagePageLayout
title={
<Stack gap={2}>
<Title order={1}>{t("publish.title")}</Title>
<Text size="sm" c="dimmed">
{widget.name}
</Text>
</Stack>
}
breadcrumb={breadcrumb}
primaryAction={<WorkshopAccountButton session={session} />}
>
<Stack gap="md">
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-default-hover)">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" radius="md" size="lg">
<IconBuildingStore size={20} />
</ThemeIcon>
<Box>
<Text fw={600}>{t("publish.introTitle")}</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.introDescription")}
</Text>
</Box>
</Group>
</Paper>
{!session.user && <Alert color="blue">{t("publish.signInHint")}</Alert>}
<Paper withBorder radius="md" p="md">
<Stack gap="md">
<Box>
<Text fw={600} size="sm">
{t("publish.listingDetails")}
</Text>
<Text c="dimmed" size="xs" mt={2}>
{t("publish.listingDetailsDescription")}
</Text>
</Box>
<TextInput
label={t("publish.titleField")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.descriptionField")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
minRows={4}
autosize
maxRows={8}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
placeholder={t("publish.screenshotsPlaceholder")}
leftSection={<IconPhoto size={16} />}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
clearable
/>
</Stack>
</Paper>
{privateSourceNames.length > 0 && (
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
{t("publish.privateSourceWarning", {
sources: privateSourceNames.join(", "),
})}
<Checkbox
mt="sm"
checked={sourceUrlsReviewed}
onChange={(event) => setSourceUrlsReviewed(event.currentTarget.checked)}
label={t("publish.privateSourceConfirmation")}
/>
</Alert>
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<ManageStickyFooter
secondary={
<Text c="dimmed" size="xs" maw={420}>
{t("publish.publicationNote")}
</Text>
}
>
<Button component={Link} href={listHref} variant="default">
{t("publish.cancel")}
</Button>
<Button
size="md"
loading={createSubmission.isPending || definition.isFetching}
disabled={blocked}
onClick={() => void publish()}
>
{t("publish.action")}
</Button>
</ManageStickyFooter>
</Stack>
</ManagePageLayout>
);
}
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { WorkshopPublishForm } from "./_workshop-publish-form";
interface WorkshopPublishPageProps {
params: Promise<{ id: string }>;
}
export default async function WorkshopPublishPage(props: WorkshopPublishPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { id } = await props.params;
const definition = await api.customWidget.get({ id }).catch(catchTrpcNotFound);
return <WorkshopPublishForm widget={{ id, name: definition.name }} />;
}
@@ -0,0 +1,284 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Card,
Group,
Paper,
Skeleton,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconFlag } from "@tabler/icons-react";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop/schema";
import { useCustomWidgetImport } from "~/components/custom-widgets/use-custom-widget-import";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
import { useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
const browseHref = "/manage/custom-widgets/workshop";
const installedHref = "/manage/custom-widgets";
export function WorkshopDetail({ id }: { id: string }) {
const t = useScopedI18n("workshop");
const router = useRouter();
const session = useWorkshopSession();
const [reportOpened, reportControls] = useDisclosure(false);
const detail = useWorkshopSubmissionQuery(session.client, id);
const reportSummaries = useWorkshopReportSummariesQuery(session.client, detail.data?.reportCount ? id : "");
const content = detail.data?.content;
const validation = useMemo(() => (content ? validateWorkshopWidget(content) : null), [content]);
const widget = useMemo<HomarrCustomWidgetV2 | null>(
() => (validation?.success && typeof validation.data !== "string" ? validation.data : null),
[validation],
);
const compatible =
detail.data?.type === "customWidget" && widget !== null && detail.data.widgetSchema === CUSTOM_WIDGET_SCHEMA;
const importer = useCustomWidgetImport({
widget: compatible ? widget : null,
// Straight back to the installed list: the edit form is heavy to load and the
// widget is usable as-is once its sources are configured here.
onImported: () => router.push(installedHref),
});
const breadcrumb = (
<DynamicBreadcrumb dynamicMappings={detail.data ? new Map([[id, detail.data.title]]) : undefined} />
);
if (detail.isPending) {
return (
<ManagePageLayout title={<Skeleton h={36} w={280} />} breadcrumb={breadcrumb}>
<Skeleton h={420} radius="md" />
</ManagePageLayout>
);
}
if (detail.isError || !detail.data) {
return (
<ManagePageLayout title={t("title")} breadcrumb={breadcrumb}>
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("loadError")}>
<Stack gap="sm" align="flex-start">
<Text size="sm">{detail.error instanceof Error ? detail.error.message : t("unavailable")}</Text>
<Button
component={Link}
href={browseHref}
variant="light"
size="xs"
leftSection={<IconArrowLeft size={14} />}
>
{t("back")}
</Button>
</Stack>
</Alert>
</ManagePageLayout>
);
}
const submission = detail.data;
return (
<ManagePageLayout
title={
<Stack gap={2}>
<Title order={1}>{submission.title}</Title>
<Text size="sm" c="dimmed">
{t("author", {
name: submission.authorName || t("communityMember"),
})}
</Text>
</Stack>
}
breadcrumb={breadcrumb}
primaryAction={
<Group gap="xs" wrap="nowrap">
<Button component={Link} href={browseHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
{t("back")}
</Button>
<Button
component="a"
href={getWorkshopWebUrl(submission.id)}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
leftSection={<IconExternalLink size={16} />}
visibleFrom="sm"
>
{t("openCommunity")}
</Button>
</Group>
}
>
<Stack gap="md">
{submission.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{t("outdatedWarning")}
</Alert>
)}
{submission.reportCount > 0 && (
<Stack gap="xs">
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("reportWarning", { count: submission.reportCount })}
</Alert>
{reportSummaries.data?.map((summary) => (
<Card key={summary.id} withBorder radius="md" p="sm">
<Badge color="red" variant="light" mb="xs">
{t(`reportCategory.${summary.category}`)}
</Badge>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{summary.explanation}
</Text>
</Card>
))}
{reportSummaries.data?.length === 0 && (
<Text size="xs" c="dimmed">
{t("reportVisibility")}
</Text>
)}
</Stack>
)}
{!compatible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("installError")}>
{t("installErrorDescription")}
{validation && !validation.success ? ` ${validation.error}` : ""}
</Alert>
)}
{submission.description && <Text>{submission.description}</Text>}
<WorkshopScreenshots
client={session.client}
submissionId={submission.id}
title={submission.title}
screenshots={submission.screenshots}
/>
{compatible && importer.review && (
<Paper withBorder radius="md" p="md">
<Stack gap="md">
<Box>
<Text fw={600}>{t("installReviewTitle")}</Text>
<Text size="sm" c="dimmed">
{t("securityNotice")}
</Text>
</Box>
<ImportReviewContent review={importer.review} messages={importer.reviewMessages}>
<CustomWidgetSourceSetupPanel
setups={importer.setups}
values={importer.values}
onChange={importer.setValue}
messages={importer.setupMessages}
/>
</ImportReviewContent>
</Stack>
</Paper>
)}
<Accordion variant="contained" radius="md">
<Accordion.Item value="technical-details">
<Accordion.Control>{t("technicalDetails")}</Accordion.Control>
<Accordion.Panel>
<WorkshopCodeViewer
value={submission.content}
language={submission.type === "customCss" ? "css" : "json"}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<ManageStickyFooter
secondary={
<>
<WorkshopVoteControl
client={session.client}
submissionId={submission.id}
score={submission.score}
canVote={session.user !== null}
/>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconDownload size={16} />}
onClick={() => downloadSubmission(submission)}
>
{t("export")}
</Button>
{/* Wrapped: a tooltip on the disabled button itself would never fire. */}
<Tooltip label={session.user ? t("report") : t("signInHint")}>
<span>
<Button
variant="subtle"
color="red"
size="compact-sm"
leftSection={<IconFlag size={16} />}
disabled={!session.user}
onClick={reportControls.open}
>
{t("report")}
</Button>
</span>
</Tooltip>
</>
}
>
<Button
size="md"
loading={importer.pending || importer.succeeded}
disabled={!compatible || !importer.ready || importer.succeeded}
onClick={importer.importWidget}
>
{t("install")}
</Button>
</ManageStickyFooter>
</Stack>
<WorkshopReportModal
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
</ManagePageLayout>
);
}
function downloadSubmission(submission: WorkshopSubmissionDetail) {
const url = URL.createObjectURL(
new Blob([submission.content], {
type: submission.type === "customCss" ? "text/css" : "application/json",
}),
);
const link = document.createElement("a");
link.href = url;
link.download = workshopExportFilename(submission.title, submission.type);
link.click();
URL.revokeObjectURL(url);
}
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { WorkshopDetail } from "./_workshop-detail";
interface WorkshopDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function WorkshopDetailPage(props: WorkshopDetailPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { id } = await props.params;
return <WorkshopDetail id={id} />;
}
@@ -0,0 +1,155 @@
"use client";
import { useCallback } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ActionIcon, Button, Group, Select, Stack, Tooltip } from "@mantine/core";
import { IconBuildingStore, IconExternalLink } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { useWorkshopQuery } from "@homarr/workshop/backend";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopSubmissionGrid } from "~/components/workshop/workshop-submission-grid";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
import { CustomWidgetTabs } from "../_custom-widget-tabs";
export const WORKSHOP_PAGE_SIZE = 12;
interface WorkshopBrowseProps {
search: string | undefined;
sort: "top" | "newest";
page: number;
}
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
const t = useScopedI18n("workshop");
const session = useWorkshopSession();
const query = useWorkshopQuery(session.client, {
page,
perPage: WORKSHOP_PAGE_SIZE,
type: "customWidget",
sort,
search,
});
const totalPages = query.data?.totalPages ?? 1;
return (
<ManagePageLayout
title={t("title")}
primaryAction={
<Group gap="xs" wrap="nowrap">
<WorkshopAccountButton session={session} />
<Button
component="a"
href={getWorkshopWebUrl()}
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<IconExternalLink size={16} />}
visibleFrom="sm"
>
{t("openCommunity")}
</Button>
</Group>
}
toolbar={
<Stack gap="sm">
<CustomWidgetTabs active="workshop" />
<Group gap="sm" align="flex-end" wrap="nowrap">
<SearchInput placeholder={`${t("search")}...`} ariaLabel={t("search")} defaultValue={search} flexExpand />
<WorkshopSortSelect value={sort} />
</Group>
</Stack>
}
footer={totalPages > 1 ? <TablePagination total={totalPages} /> : undefined}
>
<WorkshopSubmissionGrid
client={session.client}
query={query}
ariaLabel={t("listAriaLabel")}
emptyState={
search ? (
<NoResults
icon={IconBuildingStore}
title={t("emptyFilteredTitle")}
description={t("emptyFilteredDescription", { search })}
action={{
label: t("clearSearch"),
href: "/manage/custom-widgets/workshop",
}}
/>
) : (
<NoResults icon={IconBuildingStore} title={t("empty")} description={t("emptyDescription")} />
)
}
renderActions={(item) => (
<>
<WorkshopVoteControl
client={session.client}
submissionId={item.id}
score={item.score}
canVote={session.user !== null}
size="sm"
/>
<Group gap={4} wrap="nowrap">
<Tooltip label={t("openCommunity")}>
<ActionIcon
component="a"
href={getWorkshopWebUrl(item.id)}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
size="lg"
aria-label={t("openOnWorkshop", { title: item.title })}
>
<IconExternalLink size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Button component={Link} href={`/manage/custom-widgets/workshop/${item.id}`} size="sm">
{t("install")}
</Button>
</Group>
</>
)}
/>
</ManagePageLayout>
);
}
function WorkshopSortSelect({ value }: { value: "top" | "newest" }) {
const t = useScopedI18n("workshop");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleChange = useCallback(
(next: string | null) => {
const params = new URLSearchParams(searchParams);
if (next === "newest") params.set("sort", next);
else params.delete("sort");
params.delete("page");
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
},
[pathname, router, searchParams],
);
return (
<Select
aria-label={t("sort")}
value={value}
onChange={handleChange}
allowDeselect={false}
w={160}
data={[
{ value: "top", label: t("sortTop") },
{ value: "newest", label: t("sortNewest") },
]}
/>
);
}
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { z } from "zod/v4";
import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { WorkshopBrowse } from "./_workshop-browse";
const searchParamsSchema = z.object({
search: z.string().optional(),
sort: z.enum(["top", "newest"]).catch("top"),
page: z
.string()
.regex(/^[1-9]\d*$/u)
.transform(Number)
.pipe(z.number().int().positive())
.catch(1),
});
interface WorkshopBrowsePageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function WorkshopBrowsePage(props: WorkshopBrowsePageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { search, sort, page } = searchParamsSchema.parse(await props.searchParams);
return <WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} />;
}
@@ -42,7 +42,11 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
<Text size="md">
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
statusCode: error.data.statusCode.toString(),
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
{error.data.url}
</Anchor>
),
})}
</Text>
) : (
@@ -50,7 +54,11 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
{t.rich("integration.testConnection.error.statusCode.description", {
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
statusCode: error.data.statusCode.toString(),
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
url: () => (
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
{error.data.url}
</Anchor>
),
})}
</Text>
)
@@ -66,7 +74,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
{t("integration.testConnection.error.common.cause.title")}
</Accordion.Control>
<Accordion.Panel>
<pre style={{ whiteSpace: "pre-wrap" }}>
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{error.name}: {error.message}
{"\n"}
{causeArray
@@ -1,6 +1,8 @@
import type { RouterOutputs } from "@homarr/api";
export type AnyMappedTestConnectionError = Exclude<RouterOutputs["integration"]["create"], undefined>["error"];
type IntegrationCreateErrorResult = Extract<RouterOutputs["integration"]["create"], { error: unknown }>;
export type AnyMappedTestConnectionError = IntegrationCreateErrorResult["error"];
export type MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useImperativeHandle, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
@@ -25,8 +25,16 @@ import { SecretKindsSegmentedControl } from "../../_components/secrets/integrati
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
interface EditIntegrationForm {
interface EditIntegrationFormProps {
integration: RouterOutputs["integration"]["byId"];
hideButtons?: boolean;
onSuccess?: () => void;
formRef?: React.Ref<EditIntegrationFormHandle>;
}
export interface EditIntegrationFormHandle {
submit: () => Promise<boolean>;
isDirty: () => boolean;
}
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
@@ -42,7 +50,12 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
}),
);
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
export const EditIntegrationForm = ({
integration,
hideButtons = false,
onSuccess,
formRef,
}: EditIntegrationFormProps) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind);
@@ -67,17 +80,36 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
app: integration.app ?? null,
},
});
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const { mutateAsync, isPending } = clientApi.integration.update.useMutation({
onError() {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
},
});
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const errorRef = useRef<HTMLDivElement>(null);
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
await mutateAsync(
{
const handleSubmitAsync = useCallback(
async ({ app, ...values }: FormType) => {
setError(null);
let url: string;
try {
url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
} catch {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
return false;
}
const data = await mutateAsync({
id: integration.id,
...values,
url,
@@ -86,112 +118,130 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
value: secret.value === "" ? null : secret.value,
})),
appId: app?.id ?? null,
},
{
onSuccess: (data) => {
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
return;
}
});
showSuccessNotification({
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"),
});
void utils.integration.invalidate();
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError: () => {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
},
},
);
};
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
requestAnimationFrame(() => errorRef.current?.focus());
return false;
}
showSuccessNotification({
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"),
});
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
onSuccess?.();
if (!hideButtons) {
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
}
return true;
},
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
);
useImperativeHandle(
formRef,
() => ({
submit: () =>
new Promise<boolean>((resolve) => {
form.onSubmit(
async (values) => {
try {
resolve(await handleSubmitAsync(values));
} catch {
resolve(false);
}
},
() => resolve(false),
)();
}),
isDirty: () => form.isDirty(),
}),
[form, handleSubmitAsync],
);
const isInitialSecretKinds =
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
form.values.secrets.length === initialSecretsKinds.length;
return (
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
const formFields = (
<Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
{hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
{allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl
defaultKinds={initialSecretsKinds}
secretKinds={allSecretKinds}
form={form}
/>
)}
{!isInitialSecretKinds
? null
: form.values.secrets.map((secret, index) => (
<SecretCard
key={secret.kind}
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
return resolve(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
resolve(true);
},
});
})
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
{allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
)}
{!isInitialSecretKinds
? null
: form.values.secrets.map((secret, index) => (
<SecretCard
key={secret.kind}
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
return resolve(true);
}
openConfirmModal({
title: t("integration.secrets.reset.title"),
children: t("integration.secrets.reset.message"),
onCancel: () => resolve(false),
onConfirm: () => {
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
resolve(true);
},
});
})
}
>
<IntegrationSecretInput
withAsterisk
key={kind}
kind={kind}
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
))}
{form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
</Stack>
</Fieldset>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput
withAsterisk
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
))}
{form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
</Stack>
</Fieldset>
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
{error !== null && (
<div ref={errorRef} role="alert" tabIndex={-1}>
<IntegrationTestConnectionError error={error} url={form.values.url} />
</div>
)}
{!hideButtons && (
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
@@ -200,9 +250,15 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
{t("integration.testConnection.action.edit")}
</Button>
</Group>
</Stack>
</form>
)}
</Stack>
);
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
};
type FormType = z.infer<typeof formSchema>;
@@ -19,6 +19,7 @@ import {
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -49,10 +50,12 @@ interface NewIntegrationFormProps {
kind: IntegrationKind;
initialUrl?: string;
initialName?: string;
onSuccess: () => void;
onSuccess: (result?: CreatedIntegrationResult) => void;
onCancel?: () => void;
}
export type CreatedIntegrationResult = Extract<RouterOutputs["integration"]["create"], { integration: unknown }>;
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
z.object({
hasApp: z.boolean(),
@@ -67,12 +70,18 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
const validationSchema = canCreateApps
? formSchema
: formSchema.superRefine((values, context) => {
if (!values.hasApp || values.appId !== null) return;
context.addIssue({ code: "custom", message: t("common.zod.errors.required"), path: ["appId"] });
});
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
if (hasUrlSecret) {
url = "http://localhost";
}
const form = useZodForm(formSchema, {
const form = useZodForm(validationSchema, {
initialValues: {
name: initialName ?? getIntegrationName(kind),
url,
@@ -81,7 +90,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
value: "",
})),
attemptSearchEngineCreation: true,
hasApp: true,
hasApp: canCreateApps,
appHref: url,
appId: null,
},
@@ -101,7 +110,9 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
const onMutationSuccess = (
data: CreatedIntegrationResult | { error?: AnyMappedTestConnectionError } | undefined | void,
) => {
if (data && "error" in data && data.error) {
setError(data.error);
showErrorNotification({
@@ -116,7 +127,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
message: t("integration.page.create.notification.success.message"),
});
onSuccess();
onSuccess(data && "integration" in data ? data : undefined);
};
const onMutationError = () => {
@@ -131,13 +142,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
const app = hasApp
? appId !== null
? { id: appId }
: {
name: values.name,
href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(kind),
description: null,
pingUrl: url,
}
: canCreateApps
? {
name: values.name,
href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(kind),
description: null,
pingUrl: url,
}
: undefined
: undefined;
await createIntegrationAsync(
@@ -229,8 +242,10 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
checkboxInputProps.onChange(event);
});
}}
label={t("integration.field.createApp.label")}
description={t("integration.field.createApp.description")}
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
description={t(
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
)}
/>
<Collapse expanded={form.values.hasApp}>
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Badge, Stack, Text } from "@mantine/core";
import { IconPencil, IconPlugX } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -15,6 +15,7 @@ import { TourTarget } from "~/components/layout/header/tour-target";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { getIntegrationsSectionAccess } from "../_access";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
const searchParamsSchema = z.object({ search: z.string().optional() });
@@ -29,11 +30,22 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const searchParams = searchParamsSchema.parse(await props.searchParams);
const integrations = await api.integration.all();
const {
canManageAll: hasGlobalFullAccess,
canCreate,
canAccess,
} = getIntegrationsSectionAccess(session, integrations);
if (!canAccess) notFound();
const t = await getScopedI18n("integration");
const canCreate = session.user.permissions.includes("integration-create");
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
// Without integration-full-all only the integrations that were explicitly delegated to the user
// are manageable, so the list never shows an integration they cannot open.
const manageableIntegrations = integrations.filter(
(integration) => hasGlobalFullAccess || integration.permissions.hasFullAccess,
);
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
const filteredIntegrations = integrations
const filteredIntegrations = manageableIntegrations
.filter((integration) => {
if (!query) return true;
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
@@ -53,7 +65,14 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
ariaLabel={t("page.list.ariaLabel")}
itemCount={filteredIntegrations.length}
emptyState={
hasSearch ? (
manageableIntegrations.length === 0 && !hasGlobalFullAccess ? (
<NoResults
icon={IconPlugX}
title={t("page.list.noResults.createOnlyTitle")}
description={t("page.list.noResults.createOnlyDescription")}
action={{ label: t("page.list.noResults.action"), href: "/manage/integrations/new" }}
/>
) : hasSearch ? (
<NoResults
icon={IconPlugX}
title={t("page.list.noResults.filteredTitle")}
@@ -93,24 +112,33 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
floatingPrimaryAction={canCreate}
>
{filteredIntegrations.map((integration) => (
<IntegrationItem
key={integration.id}
integration={integration}
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
/>
<IntegrationItem key={integration.id} integration={integration} />
))}
</ManageCollectionPage>
);
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
const pageContent =
filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
if (hasGlobalFullAccess) {
return pageContent;
}
return (
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("page.list.scopedNote")}
</Text>
{pageContent}
</Stack>
);
}
interface IntegrationItemProps {
integration: RouterOutputs["integration"]["all"][number];
canManage: boolean;
}
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
const t = await getScopedI18n("integration");
const kindName = getIntegrationName(integration.kind);
const safeUrl = getSafeApplicationUrl(integration.url);
@@ -143,21 +171,19 @@ const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps)
) : undefined
}
actions={
canManage ? (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={t("page.list.action.edit", { name: integration.name })}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} />
</ActionIconGroup>
) : undefined
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={t("page.list.action.edit", { name: integration.name })}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} />
</ActionIconGroup>
}
/>
);
@@ -5,7 +5,6 @@ import {
IconApi,
IconBook2,
IconBox,
IconBuildingStore,
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
@@ -45,6 +44,7 @@ import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
import { env as nextEnv } from "~/env";
import { getAppsSectionAccess, getIntegrationsSectionAccessAsync } from "./_access";
const logger = createLogger({ module: "manageLayout" });
@@ -66,6 +66,8 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
sessionPromise,
shouldRunManageTourPromise,
]);
const appsAccess = getAppsSectionAccess(session);
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
const navigationLinks: NavigationLink[] = [
{
label: t("items.home"),
@@ -83,7 +85,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconBox,
href: "/manage/apps",
label: t("items.apps"),
hidden: !session?.user.permissions.includes("app-create"),
hidden: !appsAccess.canAccess,
iconProps: {
strokeWidth: 2.5,
},
@@ -93,7 +95,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconAffiliateFilled,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session?.user.permissions.includes("integration-create"),
hidden: !integrationsAccess.canAccess,
"data-onboarding-tour-id": "manage-integrations",
},
{
@@ -102,12 +104,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.customWidgets"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconBuildingStore,
href: "/manage/workshop",
label: t("items.workshop"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
@@ -1,6 +1,7 @@
import { Center, Stack, Text, Title } from "@mantine/core";
import { Anchor, Center, Stack, Text, Title } from "@mantine/core";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
export default async function NotFound() {
const t = await getScopedI18n("management.notFound");
@@ -9,6 +10,9 @@ export default async function NotFound() {
<Stack align="center">
<Title order={1}>{t("title")}</Title>
<Text>{t("text")}</Text>
<Anchor component={Link} href="/manage">
{t("backToHome")}
</Anchor>
</Stack>
</Center>
);
+7 -2
View File
@@ -29,7 +29,12 @@ export default async function ManagementPage() {
<Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{statistics.map((statistic) => (
<Card component={Link} href={statistic.path} key={statistic.path}>
<Card
component={Link}
href={statistic.path}
key={statistic.path}
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`}
>
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="xl" fw={700}>
@@ -42,7 +47,7 @@ export default async function ManagementPage() {
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
</Stack>
</Group>
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
</Group>
</Card>
))}
@@ -29,6 +29,7 @@ export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) =>
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={AppearanceCustomOption}
withinPortal
/>
</SectionCard>
);
@@ -2,7 +2,7 @@
import { Switch, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
@@ -12,11 +12,11 @@ import type { FormValues } from "./settings-form";
interface BoardSettingsFormProps {
form: UseFormReturnType<FormValues>;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
}
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return (
<SectionCard title={tBoard("title")}>
@@ -26,6 +26,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
clearable
boards={selectableBoards}
{...form.getInputProps("homeBoardId")}
withinPortal
/>
<BoardSelect
@@ -34,6 +35,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
clearable
boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")}
withinPortal
/>
<Text fw={500}>{tBoard("status.title")}</Text>
@@ -22,6 +22,7 @@ export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
label={tCulture("defaultLocale.label")}
{...localeInputProps}
value={localeInputProps.value as SupportedLanguage}
withinPortal
/>
</SectionCard>
);
@@ -2,7 +2,7 @@
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
@@ -11,11 +11,11 @@ import type { FormValues } from "./settings-form";
interface SearchSettingsFormProps {
form: UseFormReturnType<FormValues>;
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
}
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<SectionCard title={tSearch("title")}>
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { Button, Stack } from "@mantine/core";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
@@ -58,9 +59,11 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
interface SettingsFormProps {
initialData: ServerSettings;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
}
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
@@ -169,13 +172,13 @@ export const SettingsForm = ({ initialData }: SettingsFormProps) => {
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
<BoardSettingsForm form={form} />
<BoardSettingsForm form={form} selectableBoards={selectableBoards} />
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} />
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
<AppearanceSettingsForm form={form} />
<CultureSettingsForm form={form} />
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
{form.isDirty() && (
<UnsavedChangesBar>
@@ -23,12 +23,20 @@ export default async function SettingsPage() {
notFound();
}
const serverSettings = await api.serverSettings.getAll();
const tSettings = await getScopedI18n("management.page.settings");
const [serverSettings, selectableBoards, selectableSearchEngines, tSettings] = await Promise.all([
api.serverSettings.getAll(),
api.board.getPublicBoards(),
api.searchEngine.getSelectable({ withIntegrations: false }),
getScopedI18n("management.page.settings"),
]);
return (
<ManagePageLayout title={tSettings("title")}>
<SettingsForm initialData={serverSettings} />
<SettingsForm
initialData={serverSettings}
selectableBoards={selectableBoards}
selectableSearchEngines={selectableSearchEngines}
/>
</ManagePageLayout>
);
}
@@ -0,0 +1,80 @@
import { describe, expect, test } from "vitest";
import {
dismissDockerReconciliationCandidate,
filterDockerReconciliationInbox,
getTemplateUrl,
} from "./docker-reconciliation-inbox";
const candidates = [
{ candidateKey: "home:new", state: "newRecognized" as const },
{ candidateKey: "home:new-app", state: "newApp" as const },
{ candidateKey: "remote:moved", state: "moved" as const },
{ candidateKey: "home:linked", state: "linked" as const },
{ candidateKey: "home:represented", state: "represented" as const },
];
describe("Docker reconciliation inbox", () => {
test("separates attention items from represented services", () => {
expect(
filterDockerReconciliationInbox(candidates, "attention", []).map(({ candidateKey }) => candidateKey),
).toEqual(["home:new", "home:new-app", "remote:moved"]);
expect(
filterDockerReconciliationInbox(candidates, "represented", []).map(({ candidateKey }) => candidateKey),
).toEqual(["home:linked", "home:represented"]);
});
test("dismisses only the endpoint-qualified candidate key and remains idempotent", () => {
const dismissed = dismissDockerReconciliationCandidate([], "home:new");
expect(
filterDockerReconciliationInbox(candidates, "all", dismissed).map(({ candidateKey }) => candidateKey),
).not.toContain("home:new");
expect(filterDockerReconciliationInbox(candidates, "all", dismissed)).toContainEqual({
candidateKey: "remote:moved",
state: "moved",
});
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
});
test("builds service URLs from the server origin and published ports", () => {
expect(
getTemplateUrl(
{
container: { name: "Custom service", ports: [{ PrivatePort: 3000, Type: "tcp" }] },
match: null,
},
"home.lan",
"hostPort",
),
).toBe("http://home.lan");
expect(
getTemplateUrl(
{
container: {
name: "Sonarr",
ports: [
{ PrivatePort: 3000, PublicPort: 3000, Type: "tcp" },
{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" },
],
},
match: { kind: "sonarr" },
},
"home.lan",
"hostPort",
),
).toBe("http://home.lan:9999");
expect(
getTemplateUrl(
{
container: { name: "Sonarr", ports: [{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" }] },
match: { kind: "sonarr" },
},
"",
"hostPort",
),
).toBe("");
});
});
@@ -0,0 +1,54 @@
import type { DockerServiceMatch, DockerServicePort, UrlTemplateMode } from "@homarr/definitions";
import { buildAppUrl, buildIntegrationUrl, getIntegrationDefaultPort } from "@homarr/definitions";
export type DockerReconciliationInboxFilter = "attention" | "represented" | "all";
interface DockerReconciliationInboxCandidate {
candidateKey: string;
state: "newRecognized" | "newApp" | "represented" | "linked" | "moved";
}
const attentionStates = new Set<DockerReconciliationInboxCandidate["state"]>(["newRecognized", "newApp", "moved"]);
export const filterDockerReconciliationInbox = <Candidate extends DockerReconciliationInboxCandidate>(
candidates: Candidate[],
filter: DockerReconciliationInboxFilter,
dismissedCandidateKeys: string[],
) => {
const dismissed = new Set(dismissedCandidateKeys);
return candidates.filter((candidate) => {
if (dismissed.has(candidate.candidateKey)) return false;
if (filter === "attention") return attentionStates.has(candidate.state);
if (filter === "represented") return candidate.state === "represented" || candidate.state === "linked";
return true;
});
};
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
interface DockerTemplateCandidate {
container: {
name: string;
ports?: DockerServicePort[];
};
match: Pick<DockerServiceMatch, "kind"> | null;
}
export const getTemplateUrl = (candidate: DockerTemplateCandidate, serverOrigin: string, urlMode: UrlTemplateMode) => {
if (!serverOrigin.trim()) return "";
const tcpPorts = candidate.container.ports?.filter(({ Type }) => Type.toLowerCase() === "tcp") ?? [];
let publishedPort = tcpPorts.find(({ PublicPort }) => PublicPort)?.PublicPort;
if (candidate.match) {
const defaultPort = getIntegrationDefaultPort(candidate.match.kind);
const preferredPort = tcpPorts.find(
({ PrivatePort, PublicPort }) => PrivatePort === defaultPort && PublicPort !== undefined,
);
publishedPort = preferredPort?.PublicPort ?? publishedPort;
return buildIntegrationUrl(candidate.match.kind, serverOrigin, urlMode, publishedPort);
}
return buildAppUrl(candidate.container.name, serverOrigin, urlMode, publishedPort);
};
@@ -0,0 +1,432 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import {
Accordion,
ActionIcon,
Alert,
Autocomplete,
Avatar,
Badge,
Button,
Collapse,
Divider,
Group,
Paper,
SegmentedControl,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import {
IconAlertTriangle,
IconApps,
IconCheck,
IconChevronDown,
IconEye,
IconEyeOff,
IconBrandDocker,
IconMinus,
IconPlugConnected,
IconRefresh,
IconSettings,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { UrlTemplateMode } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification } from "@homarr/notifications";
import { normalizeServiceUrl, ServiceUrlTemplate } from "@homarr/onboarding";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
import {
dismissDockerReconciliationCandidate,
filterDockerReconciliationInbox,
getTemplateUrl,
} from "./docker-reconciliation-inbox";
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOrigin: string }) => {
const t = useScopedI18n("docker.reconciliation");
const tGlobal = useI18n();
const utils = clientApi.useUtils();
const panelId = useId();
const [isOpen, setIsOpen] = useLocalStorage({
key: "homarr-docker-reconciliation-open",
defaultValue: false,
});
const reconciliation = clientApi.docker.reconcileServices.useQuery();
const health = clientApi.docker.getServiceHealth.useQuery(undefined, { enabled: isOpen });
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
async onSuccess() {
await Promise.all([
utils.docker.getContainers.invalidate(),
utils.docker.reconcileServices.invalidate(),
utils.docker.getServiceHealth.invalidate(),
]);
},
onError() {
showErrorNotification({
title: t("refreshError.title"),
message: t("refreshError.message"),
});
},
});
const [filter, setFilter] = useState<DockerReconciliationInboxFilter>("attention");
const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage<string[]>({
key: "homarr-docker-reconciliation-dismissed",
defaultValue: [],
});
const [serverOrigin, setServerOrigin] = useLocalStorage({
key: "homarr-docker-service-origin",
defaultValue: defaultServerOrigin,
});
const [urlMode, setUrlMode] = useLocalStorage<UrlTemplateMode>({
key: "homarr-docker-service-url-mode",
defaultValue: "hostPort",
});
if (reconciliation.isError) {
return (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("loadError.title")}>
<Stack gap="sm">
<Text size="sm">{t("loadError.message")}</Text>
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
{tGlobal("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
if (!reconciliation.data) return null;
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys);
const attentionCount = reconciliation.data.candidates.filter(
({ candidateKey, state }) =>
["newRecognized", "newApp", "moved"].includes(state) && !dismissedCandidateKeys.includes(candidateKey),
).length;
const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching;
const toggleLabel = isOpen ? tGlobal("common.action.hide") : t("action.review");
return (
<Paper withBorder p="sm">
<Stack gap={isOpen ? "sm" : 0}>
<Group justify="space-between" align="start">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" size="lg" radius="md">
<IconBrandDocker size={18} />
</ThemeIcon>
<div>
<Group gap="xs">
<Text fw={600}>{t("title")}</Text>
<Badge variant="light" color={attentionCount > 0 ? "blue" : "gray"} aria-live="polite">
{t("suggestions", { count: String(attentionCount) })}
</Badge>
</Group>
</div>
</Group>
<Button
variant="subtle"
size="compact-sm"
rightSection={
<IconChevronDown
size={15}
style={{ transform: isOpen ? "rotate(180deg)" : undefined, transition: "transform 150ms ease" }}
/>
}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setIsOpen((current) => !current)}
>
{toggleLabel}
</Button>
</Group>
<Collapse id={panelId} expanded={isOpen}>
<Divider mb="sm" />
<Stack gap="sm">
<ServiceUrlTemplate
serverOrigin={serverOrigin}
onServerOriginChange={setServerOrigin}
mode={urlMode}
onModeChange={setUrlMode}
/>
<Group justify="space-between">
<SegmentedControl
size="xs"
value={filter}
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
data={[
{ value: "attention", label: t("filter.attention") },
{ value: "represented", label: t("filter.represented") },
{ value: "all", label: t("filter.all") },
]}
/>
<Group gap="xs">
{dismissedCandidateKeys.length > 0 && (
<Button variant="subtle" size="compact-xs" onClick={() => setDismissedCandidateKeys([])}>
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
</Button>
)}
<Button
variant="light"
size="compact-xs"
loading={isRefreshing}
leftSection={<IconRefresh size={14} />}
onClick={() => refreshInventory.mutate()}
>
{tGlobal("docker.action.refresh.label")}
</Button>
</Group>
</Group>
{unavailableEndpoints.length > 0 && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
</Alert>
)}
{candidates.length === 0 ? (
<Text c="dimmed" size="sm">
{reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")}
</Text>
) : (
<Accordion multiple variant="separated" radius="sm" chevron={null}>
{candidates.map((candidate) => (
<DockerReconciliationCandidate
key={candidate.candidateKey}
candidate={candidate}
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
serverOrigin={serverOrigin}
urlMode={urlMode}
onDismiss={() =>
setDismissedCandidateKeys((current) =>
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
)
}
/>
))}
</Accordion>
)}
</Stack>
</Collapse>
</Stack>
</Paper>
);
};
const DockerReconciliationCandidate = ({
candidate,
health,
serverOrigin,
urlMode,
onDismiss,
}: {
candidate: ReconciliationCandidate;
health: ServiceHealth | undefined;
serverOrigin: string;
urlMode: UrlTemplateMode;
onDismiss: () => void;
}) => {
const t = useScopedI18n("docker.reconciliation");
const tGlobal = useI18n();
const { openModal: openAppModal } = useModalAction(AddDockerAppToHomarr);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const templateUrl = getTemplateUrl(candidate, serverOrigin, urlMode);
const urlSuggestions = Array.from(
new Set([templateUrl, ...candidate.urlCandidates.map(({ url }) => url)].filter((url) => url.length > 0)),
);
const initialUrl = urlSuggestions[0] ?? "";
const [url, setUrl] = useState(initialUrl);
const isUrlEdited = useRef(false);
const target = getCandidateTarget(candidate);
const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
const visibleHealthLayers =
health?.layers.filter(({ status }) => status !== "notApplicable" && status !== "notObserved") ?? [];
useEffect(() => {
if (isUrlEdited.current) return;
setUrl(initialUrl);
}, [initialUrl]);
return (
<Accordion.Item value={candidate.candidateKey}>
<Group wrap="nowrap" gap={4} pr="sm">
<Accordion.Control
style={{ flex: 1 }}
icon={
<Avatar src={candidate.container.iconUrl} radius="sm" size="sm">
{candidate.container.name.at(0)?.toUpperCase()}
</Avatar>
}
>
<div style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text fw={600} size="sm" truncate>
{candidate.container.name}
</Text>
<Badge color={stateColor(candidate.state)} variant="light" size="xs">
{t(`state.${candidate.state}`)}
</Badge>
</Group>
<Text c="dimmed" size="xs">
{candidate.endpointName}
</Text>
</div>
</Accordion.Control>
<Group gap={4} wrap="nowrap">
{target.kind === "createApp" && (
<Button
variant="light"
size="compact-xs"
leftSection={<IconApps size={14} />}
onClick={() =>
openAppModal({
selectedContainers: [candidate.container],
initialUrls: [(normalizeServiceUrl(url) ?? url) || null],
})
}
>
{tGlobal("integration.field.createApp.label")}
</Button>
)}
{target.kind === "setupIntegration" && (
<Button
variant="light"
size="compact-xs"
leftSection={<IconPlugConnected size={14} />}
onClick={() =>
openIntegrationModal({
initialKind: target.integrationKind,
initialName: candidate.container.name,
initialUrl: normalizeServiceUrl(url) ?? url,
})
}
>
{t("action.setupIntegration")}
</Button>
)}
{(target.kind === "reviewIntegration" || target.kind === "viewRepresentation") && (
<Button
component={Link}
href={target.href}
variant="light"
size="compact-xs"
leftSection={target.kind === "reviewIntegration" ? <IconSettings size={14} /> : <IconEye size={14} />}
>
{t(`action.${target.kind}`)}
</Button>
)}
<Tooltip label={t("action.dismiss")} events={{ hover: true, focus: true, touch: false }}>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("action.dismiss")} onClick={onDismiss}>
<IconEyeOff size={15} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Accordion.Panel>
<Stack gap="sm">
{candidate.representation.signals.ambiguous && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("ambiguous")}
</Alert>
)}
{visibleHealthLayers.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xs">
{visibleHealthLayers.map((layer) => (
<Group key={layer.layer} gap="xs" wrap="nowrap">
<HealthStatusIcon status={layer.status} />
<Text size="xs">
{t(`health.layer.${layer.layer}`)} ·{" "}
<Text component="span" c="dimmed" inherit>
{t(`health.status.${layer.status}`)}
</Text>
</Text>
</Group>
))}
</SimpleGrid>
)}
{actionNeedsUrl && (
<Autocomplete
label={tGlobal("integration.field.url.label")}
value={url}
data={urlSuggestions}
placeholder={t("url.manual")}
onChange={(value) => {
isUrlEdited.current = true;
setUrl(value);
}}
/>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
};
const getCandidateTarget = (candidate: ReconciliationCandidate) => {
if (candidate.representation.signals.ambiguous) {
if (candidate.nextAction === "reviewIntegration" && candidate.match) {
const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) });
return { kind: "reviewIntegration" as const, href: `/manage/integrations?${params.toString()}` };
}
return { kind: "viewRepresentation" as const, href: "/manage/apps" };
}
if (candidate.state === "newRecognized" && candidate.match) {
return { kind: "setupIntegration" as const, integrationKind: candidate.match.kind };
}
if (candidate.state === "newApp") return { kind: "createApp" as const };
if (candidate.representation.integration) {
return {
kind: candidate.state === "moved" ? ("reviewIntegration" as const) : ("viewRepresentation" as const),
href: `/manage/integrations/edit/${candidate.representation.integration.id}`,
};
}
if (candidate.representation.app) {
return { kind: "viewRepresentation" as const, href: `/manage/apps/edit/${candidate.representation.app.id}` };
}
return { kind: "createApp" as const };
};
const stateColor = (state: ReconciliationCandidate["state"]) => {
if (state === "linked" || state === "represented") return "green";
if (state === "moved") return "yellow";
return "blue";
};
const HealthStatusIcon = ({ status }: { status: ServiceHealth["layers"][number]["status"] }) => {
let color = "gray";
let icon = <IconMinus size={12} />;
if (["available", "configured", "linked"].includes(status)) {
color = "green";
icon = <IconCheck size={12} />;
} else if (["missing", "changed", "unused"].includes(status)) {
color = "yellow";
icon = <IconAlertTriangle size={12} />;
}
return (
<ThemeIcon color={color} variant="light" radius="xl" size="sm">
{icon}
</ThemeIcon>
);
};
@@ -1,10 +1,10 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Alert, Avatar, Badge, Box, Button, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCategoryPlus,
IconDots,
IconFileText,
@@ -20,10 +20,10 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import type { ContainerState, DockerEndpointCapability } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -32,9 +32,41 @@ import { OverflowBadge } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
type DockerEndpoint = RouterOutputs["docker"]["getContainers"]["endpoints"][number];
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id });
const showContainerActionResult = (
action: "start" | "stop" | "restart" | "remove",
containers: DockerContainer[],
results: RouterOutputs["docker"]["startAll"],
t: TranslationFunction,
) => {
const failures = results.filter((result) => !result.success);
if (failures.length === 0) {
showSuccessNotification({
title: t(`docker.action.${action}.notification.success.title`),
message: t(`docker.action.${action}.notification.success.message`),
});
return;
}
const failedNames = failures.map(
({ target }) =>
containers.find(({ endpointId, id }) => endpointId === target.endpointId && id === target.id)?.name ?? target.id,
);
showErrorNotification({
title: t("docker.action.result.failure.title"),
message: t("docker.action.result.failure.message", {
failed: String(failures.length),
total: String(results.length),
names: failedNames.join(", "),
}),
});
};
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
{
@@ -157,32 +189,39 @@ interface DockerTableProps {
export function DockerTable({ initialData }: DockerTableProps) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
const utils = clientApi.useUtils();
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
refetchOnMount: false,
});
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
async onSuccess() {
await Promise.all([
utils.docker.getContainers.invalidate(),
utils.docker.reconcileServices.invalidate(),
utils.docker.getServiceHealth.invalidate(),
]);
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
const mutate = useCallback(() => {
void refetch().then((result) => {
if (result.isError) {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
} else {
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
}
});
}, [refetch, tDocker]);
const containers = data?.containers ?? [];
const unavailableEndpoints = data?.endpoints.filter(({ status }) => status === "unavailable") ?? [];
const table = useTranslatedMantineReactTable({
data: containers,
state: { isLoading: isFetching },
getRowId: (row) => row.resourceId,
state: { isLoading: isFetching || refreshInventory.isPending },
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
@@ -207,13 +246,18 @@ export function DockerTable({ initialData }: DockerTableProps) {
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => (
<ContainerRowMenu
container={row.original}
endpoint={data?.endpoints.find(({ id }) => id === row.original.endpointId)}
/>
),
renderTopToolbarCustomActions: () => (
<Button
variant="default"
rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()}
loading={isFetching}
onClick={() => refreshInventory.mutate()}
loading={isFetching || refreshInventory.isPending}
>
{tDocker("action.refresh.label")}
</Button>
@@ -229,7 +273,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
totalCount: String(table.getRowCount()),
})}
</Text>
<ContainerActionBar selectedContainers={dockerContainers} />
<ContainerActionBar selectedContainers={dockerContainers} endpoints={data?.endpoints ?? []} />
</Group>
);
},
@@ -238,10 +282,18 @@ export function DockerTable({ initialData }: DockerTableProps) {
});
return (
<>
<Stack gap="xs">
{unavailableEndpoints.length > 0 && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{tDocker("error.endpointUnavailable", {
count: String(unavailableEndpoints.length),
names: unavailableEndpoints.map(({ name }) => name).join(", "),
})}
</Alert>
)}
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
<MantineReactTable table={table} />
</>
</Stack>
);
}
@@ -252,22 +304,21 @@ const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon:
{ action: "remove", icon: IconTrash, color: "red" },
];
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer; endpoint?: DockerEndpoint }) => {
const t = useScopedI18n("docker.action");
const tAll = useI18n();
const router = useRouter();
const utils = clientApi.useUtils();
const { openModal } = useModalAction(AddDockerAppToHomarr);
const confirmRemoval = useDockerContainerRemovalConfirmation();
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
clientApi.docker[`${action}All`].useMutation({
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
});
onSuccess(results) {
showContainerActionResult(action, [container], results, tAll);
},
onError() {
showErrorNotification({
@@ -294,6 +345,7 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
<Menu.Dropdown>
<Menu.Item
leftSection={<IconFileText size="1rem" />}
disabled={!endpointHasCapability(endpoint, "logs")}
onClick={() => router.push(createContainerLogsPath(container))}
>
{t("logs.label")}
@@ -304,7 +356,12 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
key={action}
leftSection={<Icon size="1rem" />}
color={color}
onClick={() => mutations[action].mutate({ ids: [container.id] })}
disabled={!endpointHasCapability(endpoint, action === "remove" ? "remove" : "lifecycle")}
onClick={() => {
const runAction = () => mutations[action].mutate({ targets: [getContainerTarget(container)] });
if (action === "remove") confirmRemoval([container], runAction);
else runAction();
}}
>
{t(`${action}.label`)}
</Menu.Item>
@@ -324,18 +381,24 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
interface ContainerActionBarProps {
selectedContainers: DockerContainer[];
endpoints: DockerEndpoint[];
}
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
const ContainerActionBar = ({ selectedContainers, endpoints }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const selectedIds = selectedContainers.map((container) => container.id);
return (
<Group gap="xs">
{containerActions.map(({ action, icon, color }) => (
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
<ContainerActionBarButton
key={action}
icon={icon}
color={color}
action={action}
selectedContainers={selectedContainers}
endpoints={endpoints}
/>
))}
<Button
leftSection={<IconCategoryPlus />}
@@ -353,12 +416,22 @@ interface ContainerActionBarButtonProps {
icon: TablerIcon;
color: MantineColor;
action: "start" | "stop" | "restart" | "remove";
selectedIds: string[];
selectedContainers: DockerContainer[];
endpoints: DockerEndpoint[];
}
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
const t = useScopedI18n("docker.action");
const tAll = useI18n();
const utils = clientApi.useUtils();
const confirmRemoval = useDockerContainerRemovalConfirmation();
const requiredCapability = props.action === "remove" ? "remove" : "lifecycle";
const isAllowed = props.selectedContainers.every((container) =>
endpointHasCapability(
props.endpoints.find(({ id }) => id === container.endpointId),
requiredCapability,
),
);
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation({
async onSettled() {
@@ -367,23 +440,28 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
});
const handleClickAsync = async () => {
await mutateAsync(
{ ids: props.selectedIds },
{
onSuccess() {
showSuccessNotification({
title: t(`${props.action}.notification.success.title`),
message: t(`${props.action}.notification.success.message`),
});
const runActionAsync = async () => {
await mutateAsync(
{ targets: props.selectedContainers.map(getContainerTarget) },
{
onSuccess(results) {
showContainerActionResult(props.action, props.selectedContainers, results, tAll);
},
onError() {
showErrorNotification({
title: t(`${props.action}.notification.error.title`),
message: t(`${props.action}.notification.error.message`),
});
},
},
onError() {
showErrorNotification({
title: t(`${props.action}.notification.error.title`),
message: t(`${props.action}.notification.error.message`),
});
},
},
);
);
};
if (props.action === "remove") {
confirmRemoval(props.selectedContainers, runActionAsync);
return;
}
await runActionAsync();
};
return (
@@ -392,6 +470,7 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
color={props.color}
onClick={handleClickAsync}
loading={isPending}
disabled={!isAllowed}
variant="light"
>
{t(`${props.action}.label`)}
@@ -399,6 +478,9 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
);
};
const endpointHasCapability = (endpoint: DockerEndpoint | undefined, capability: DockerEndpointCapability) =>
endpoint && "capabilities" in endpoint ? endpoint.capabilities.includes(capability) : true;
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
@@ -13,7 +13,7 @@ import { ClientSideDockerLogsTerminal } from "./client";
interface DockerContainerLogsPageProps {
params: Promise<{ containerId: string }>;
searchParams: Promise<{ name?: string }>;
searchParams: Promise<{ endpointId?: string; name?: string }>;
}
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
@@ -23,10 +23,12 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
}
const { containerId } = await params;
const { name } = await searchParams;
const { endpointId, name } = await searchParams;
const resolvedEndpointId = endpointId ?? (await getOnlyDockerEndpointIdAsync());
if (!resolvedEndpointId) notFound();
try {
await api.docker.logs({ id: containerId, tail: 1 });
await api.docker.logs({ endpointId: resolvedEndpointId, id: containerId, tail: 1 });
} catch {
notFound();
}
@@ -40,8 +42,17 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
/>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal containerId={containerId} />
<ClientSideDockerLogsTerminal endpointId={resolvedEndpointId} containerId={containerId} />
</Box>
</>
);
}
const getOnlyDockerEndpointIdAsync = async () => {
try {
const endpoints = await api.docker.getEndpoints();
return endpoints.length === 1 ? endpoints[0]?.id : undefined;
} catch {
return undefined;
}
};
@@ -11,10 +11,11 @@ import { clientApi } from "@homarr/api/client";
import classes from "./terminal.module.css";
interface DockerLogsTerminalProps {
endpointId: string;
containerId: string;
}
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
export const DockerLogsTerminal = ({ endpointId, containerId }: DockerLogsTerminalProps) => {
const ref = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const pendingLogsRef = useRef<string[]>([]);
@@ -31,7 +32,7 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
}, []);
clientApi.docker.subscribeLogs.useSubscription(
{ id: containerId, tail: 200 },
{ endpointId, id: containerId, tail: 200 },
{
onData: writeToTerminal,
onError(err) {
@@ -1,12 +1,15 @@
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { DockerReconciliation } from "./docker-reconciliation";
import { DockerTable } from "./docker-table";
export default async function DockerPage() {
@@ -15,7 +18,10 @@ export default async function DockerPage() {
notFound();
}
const { containers, timestamp } = await api.docker.getContainers();
const [{ containers, endpoints, timestamp }, requestHeaders] = await Promise.all([
api.docker.getContainers(),
headers(),
]);
const tDocker = await getScopedI18n("docker");
return (
@@ -23,7 +29,8 @@ export default async function DockerPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable initialData={{ containers, timestamp }} />
<DockerReconciliation defaultServerOrigin={extractBaseUrlFromHeaders(requestHeaders)} />
<DockerTable initialData={{ containers, endpoints, timestamp }} />
</Stack>
</>
);
@@ -1,31 +1,38 @@
"use client";
import { SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
import { Alert, SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import type { KubernetesLabelResourceType } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import KubernetesErrorPage from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/error";
import { HeaderCard } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card";
import { ResourceGauge } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge";
import { ResourceTile } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile";
import { useSelectedKubernetesContextId } from "../kubernetes-context-selector";
export function ClusterDashboard() {
const t = useI18n();
const tCluster = useScopedI18n("kubernetes.cluster");
const contextId = useSelectedKubernetesContextId();
const {
data: clusterData,
isLoading: isClusterLoading,
isError: isClusterError,
} = clientApi.kubernetes.cluster.getCluster.useQuery();
} = clientApi.kubernetes.cluster.getCluster.useQuery({ contextId: contextId ?? "" }, { enabled: Boolean(contextId) });
const {
data: resourceCountsData,
isLoading: isResourceCountsLoading,
isError: isResourceCountsError,
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery();
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery(
{ contextId: contextId ?? "" },
{ enabled: Boolean(contextId) },
);
return (
<Stack bg="var(--mantine-color-body)">
@@ -46,6 +53,12 @@ export function ClusterDashboard() {
<Title>{t("kubernetes.cluster.capacity.title")}</Title>
{clusterData?.metricsAvailable === false && (
<Alert color="blue" variant="light" icon={<IconInfoCircle size={18} />}>
{tCluster("capacity.metricsUnavailable")}
</Alert>
)}
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{isClusterError
? Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />)
@@ -63,9 +76,11 @@ export function ClusterDashboard() {
? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />)
: isResourceCountsLoading
? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />)
: resourceCountsData?.map((clusterResourceCount) => (
: contextId &&
resourceCountsData?.map((clusterResourceCount) => (
<ResourceTile
count={clusterResourceCount.count}
contextId={contextId}
label={clusterResourceCount.label as KubernetesLabelResourceType}
key={clusterResourceCount.label}
/>
@@ -11,13 +11,18 @@ import classes from "./resource-tile.module.css";
interface ResourceTileProps {
count: number;
contextId: string;
label: KubernetesLabelResourceType;
}
export function ResourceTile(props: ResourceTileProps) {
const t = useI18n();
return (
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
<Card
component={Link}
href={`/manage/tools/kubernetes/${props.label}?context=${encodeURIComponent(props.contextId)}`}
className={classes.cardContainer}
>
<Group justify="space-between" wrap="nowrap">
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
<Group gap="xs">
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface ConfigMapsTableComponentProps {
contextId: string;
initialConfigMaps: RouterOutputs["kubernetes"]["configMaps"]["getConfigMaps"];
}
@@ -39,15 +40,19 @@ const createColumns = (
},
];
export function ConfigmapsTable(initialData: ConfigMapsTableComponentProps) {
export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTableComponentProps) {
const tConfigMaps = useScopedI18n("kubernetes.configmaps");
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(undefined, {
initialData: initialData.initialConfigMaps,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
{ contextId },
{
initialData: initialConfigMaps,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,14 +8,24 @@ import { getScopedI18n } from "@homarr/translation/server";
import { ConfigmapsTable } from "~/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function ConfigMapsPage() {
export default async function ConfigMapsPage({
searchParams,
}: {
searchParams: Promise<KubernetesContextSearchParams>;
}) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const configMaps = await api.kubernetes.configMaps.getConfigMaps();
const context = await getSelectedKubernetesContextAsync(searchParams);
const configMaps =
context.status === "unavailable"
? []
: await api.kubernetes.configMaps.getConfigMaps({ contextId: context.contextId });
const tConfigMaps = await getScopedI18n("kubernetes.configmaps");
return (
@@ -23,7 +33,7 @@ export default async function ConfigMapsPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tConfigMaps("label")}</Title>
<ConfigmapsTable initialConfigMaps={configMaps} />
<ConfigmapsTable contextId={context.contextId} initialConfigMaps={configMaps} />
</Stack>
</>
);
@@ -16,9 +16,12 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
dayjs.extend(relativeTime);
interface IngressesTableComponentProps {
contextId: string;
initialIngresses: RouterOutputs["kubernetes"]["ingresses"]["getIngresses"];
}
@@ -72,15 +75,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.ingresses">): MR
},
];
export function IngressesTable(initialData: IngressesTableComponentProps) {
export function IngressesTable({ contextId, initialIngresses }: IngressesTableComponentProps) {
const tIngresses = useScopedI18n("kubernetes.ingresses");
const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(undefined, {
initialData: initialData.initialIngresses,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
{ contextId },
{
initialData: initialIngresses,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -103,5 +110,9 @@ export function IngressesTable(initialData: IngressesTableComponentProps) {
columns: createColumns(tIngresses),
});
if (isError) {
return <KubernetesErrorPage />;
}
return <MantineReactTable table={table} />;
}
@@ -8,21 +8,31 @@ import { getScopedI18n } from "@homarr/translation/server";
import { IngressesTable } from "~/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function NamespacesPage() {
export default async function NamespacesPage({
searchParams,
}: {
searchParams: Promise<KubernetesContextSearchParams>;
}) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const ingresses = await api.kubernetes.ingresses.getIngresses();
const context = await getSelectedKubernetesContextAsync(searchParams);
const ingresses =
context.status === "unavailable"
? []
: await api.kubernetes.ingresses.getIngresses({ contextId: context.contextId });
const tIngresses = await getScopedI18n("kubernetes.ingresses");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tIngresses("label")}</Title>
<IngressesTable initialIngresses={ingresses} />
<IngressesTable contextId={context.contextId} initialIngresses={ingresses} />
</Stack>
</>
);
@@ -0,0 +1,115 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Alert, Badge, Button, Group, Select, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
export const KubernetesContextSelector = () => {
const t = useScopedI18n("kubernetes.context");
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { data, isError, refetch } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
refetchInterval: 30_000,
refetchOnMount: "always",
refetchOnReconnect: true,
refetchOnWindowFocus: true,
});
const [storedContextId, setStoredContextId] = useLocalStorage<string | null>({
key: "homarr-kubernetes-context",
defaultValue: null,
});
const requestedContextId = searchParams.get("context");
const contextId =
data?.contexts.find((context) => context.contextId === requestedContextId)?.contextId ??
data?.contexts.find((context) => context.contextId === storedContextId)?.contextId ??
data?.contexts.find((context) => context.contextId === data.defaultContextId)?.contextId ??
data?.contexts[0]?.contextId ??
null;
const context = data?.contexts.find((item) => item.contextId === contextId);
useEffect(() => {
if (!contextId || requestedContextId === contextId) return;
const next = new URLSearchParams(searchParams);
next.set("context", contextId);
router.replace(`${pathname}?${next.toString()}`);
}, [contextId, pathname, requestedContextId, router, searchParams]);
if (isError) {
return (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("loadError.title")}>
<Stack gap="sm">
<Text size="sm">{t("loadError.message")}</Text>
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void refetch()}>
{t("loadError.retry")}
</Button>
</Stack>
</Alert>
);
}
if (!data || !contextId) return null;
const selectContext = (value: string | null) => {
if (!value) return;
setStoredContextId(value);
const next = new URLSearchParams(searchParams);
next.set("context", value);
router.replace(`${pathname}?${next.toString()}`);
};
return (
<>
<Group justify="end">
<Select
label={t("label")}
value={contextId}
data={data.contexts.map((item) => ({ value: item.contextId, label: item.name }))}
onChange={selectContext}
allowDeselect={false}
/>
{context && (
<Badge color={contextStatusColor(context.status)} variant="light">
{t(`status.${context.status}`)}
</Badge>
)}
</Group>
{context?.status === "unavailable" && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("unavailable")}
</Alert>
)}
{context?.status === "degraded" && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
{t("metricsUnavailable")}
</Alert>
)}
</>
);
};
export const useSelectedKubernetesContextId = () => {
const searchParams = useSearchParams();
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
refetchInterval: 30_000,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
});
const requested = searchParams.get("context");
if (data?.contexts.some(({ contextId }) => contextId === requested)) return requested;
return (
data?.contexts.find(({ contextId }) => contextId === data.defaultContextId)?.contextId ??
data?.contexts[0]?.contextId
);
};
const contextStatusColor = (status: "available" | "degraded" | "unavailable") => {
if (status === "available") return "green";
if (status === "degraded") return "yellow";
return "red";
};
@@ -0,0 +1,16 @@
import { api } from "@homarr/api/server";
export interface KubernetesContextSearchParams {
context?: string | string[];
}
export const getSelectedKubernetesContextAsync = async (searchParams: Promise<KubernetesContextSearchParams>) => {
const [{ context }, result] = await Promise.all([searchParams, api.kubernetes.contexts.getContexts()]);
const requestedContextId = Array.isArray(context) ? context[0] : context;
const selected =
result.contexts.find(({ contextId }) => contextId === requestedContextId) ??
result.contexts.find(({ contextId }) => contextId === result.defaultContextId) ??
result.contexts[0];
if (!selected) throw new Error("No Kubernetes context is configured");
return selected;
};
@@ -0,0 +1,15 @@
import { Suspense } from "react";
import { Stack } from "@mantine/core";
import { KubernetesContextSelector } from "./kubernetes-context-selector";
export default function KubernetesLayout({ children }: { children: React.ReactNode }) {
return (
<Stack>
<Suspense fallback={null}>
<KubernetesContextSelector />
</Suspense>
{children}
</Stack>
);
}
@@ -18,6 +18,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface NamespacesTableComponentProps {
contextId: string;
initialNamespaces: RouterOutputs["kubernetes"]["namespaces"]["getNamespaces"];
}
@@ -56,15 +57,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.namespaces">): M
},
];
export function NamespacesTable(initialData: NamespacesTableComponentProps) {
export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTableComponentProps) {
const tNamespaces = useScopedI18n("kubernetes.namespaces");
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(undefined, {
initialData: initialData.initialNamespaces,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
{ contextId },
{
initialData: initialNamespaces,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,21 +8,31 @@ import { getScopedI18n } from "@homarr/translation/server";
import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function NamespacesPage() {
export default async function NamespacesPage({
searchParams,
}: {
searchParams: Promise<KubernetesContextSearchParams>;
}) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const namespaces = await api.kubernetes.namespaces.getNamespaces();
const context = await getSelectedKubernetesContextAsync(searchParams);
const namespaces =
context.status === "unavailable"
? []
: await api.kubernetes.namespaces.getNamespaces({ contextId: context.contextId });
const tNamespaces = await getScopedI18n("kubernetes.namespaces");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tNamespaces("label")}</Title>
<NamespacesTable initialNamespaces={namespaces} />
<NamespacesTable contextId={context.contextId} initialNamespaces={namespaces} />
</Stack>
</>
);
@@ -15,9 +15,12 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
dayjs.extend(relativeTime);
interface NodesListComponentProps {
contextId: string;
initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"];
}
@@ -49,14 +52,14 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
accessorKey: "allocatableCpuPercentage",
header: t("field.cpu.label"),
Cell({ cell }) {
return getRingProgress(cell.row.original.allocatableCpuPercentage);
return getRingProgress(cell.row.original.allocatableCpuPercentage, t("field.metricsUnavailable"));
},
},
{
accessorKey: "allocatableRamPercentage",
header: t("field.memory.label"),
Cell({ cell }) {
return getRingProgress(cell.row.original.allocatableRamPercentage);
return getRingProgress(cell.row.original.allocatableRamPercentage, t("field.metricsUnavailable"));
},
},
{
@@ -82,15 +85,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
},
];
export function NodesTable(initialData: NodesListComponentProps) {
export function NodesTable({ contextId, initialNodes }: NodesListComponentProps) {
const tNodes = useScopedI18n("kubernetes.nodes");
const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(undefined, {
initialData: initialData.initialNodes,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
{ contextId },
{
initialData: initialNodes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -112,10 +119,18 @@ export function NodesTable(initialData: NodesListComponentProps) {
columns: createColumns(tNodes),
});
if (isError) {
return <KubernetesErrorPage />;
}
return <MantineReactTable table={table} />;
}
function getRingProgress(value: number) {
function getRingProgress(value: number | null, unavailableLabel: string) {
if (value === null) {
return <Text c="dimmed">{unavailableLabel}</Text>;
}
return (
<RingProgress
size={70}
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { NodesTable } from "~/app/[locale]/manage/tools/kubernetes/nodes/nodes-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function NodesPage() {
export default async function NodesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const nodes = await api.kubernetes.nodes.getNodes();
const context = await getSelectedKubernetesContextAsync(searchParams);
const nodes =
context.status === "unavailable" ? [] : await api.kubernetes.nodes.getNodes({ contextId: context.contextId });
const tNodes = await getScopedI18n("kubernetes.nodes");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tNodes("label")}</Title>
<NodesTable initialNodes={nodes} />
<NodesTable contextId={context.contextId} initialNodes={nodes} />
</Stack>
</>
);
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function PodsPage() {
export default async function PodsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const pods = await api.kubernetes.pods.getPods();
const context = await getSelectedKubernetesContextAsync(searchParams);
const pods =
context.status === "unavailable" ? [] : await api.kubernetes.pods.getPods({ contextId: context.contextId });
const tPods = await getScopedI18n("kubernetes.pods");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tPods("label")}</Title>
<PodsTable initialPods={pods} />
<PodsTable contextId={context.contextId} initialPods={pods} />
</Stack>
</>
);
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface PodsTableComponentProps {
contextId: string;
initialPods: RouterOutputs["kubernetes"]["pods"]["getPods"];
}
@@ -47,15 +48,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.pods">): MRT_Col
},
];
export function PodsTable(initialData: PodsTableComponentProps) {
export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
const tPods = useScopedI18n("kubernetes.pods");
const { data } = clientApi.kubernetes.pods.getPods.useQuery(undefined, {
initialData: initialData.initialPods,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
{ contextId },
{
initialData: initialPods,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function SecretsPage() {
export default async function SecretsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const secrets = await api.kubernetes.secrets.getSecrets();
const context = await getSelectedKubernetesContextAsync(searchParams);
const secrets =
context.status === "unavailable" ? [] : await api.kubernetes.secrets.getSecrets({ contextId: context.contextId });
const tSecrets = await getScopedI18n("kubernetes.secrets");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tSecrets("label")}</Title>
<SecretsTable initialSecrets={secrets} />
<SecretsTable contextId={context.contextId} initialSecrets={secrets} />
</Stack>
</>
);
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface SecretsTableComponentProps {
contextId: string;
initialSecrets: RouterOutputs["kubernetes"]["secrets"]["getSecrets"];
}
@@ -42,15 +43,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.secrets">): MRT_
},
];
export function SecretsTable(initialData: SecretsTableComponentProps) {
export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponentProps) {
const tSecrets = useScopedI18n("kubernetes.secrets");
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(undefined, {
initialData: initialData.initialSecrets,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
{ contextId },
{
initialData: initialSecrets,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function ServicesPage() {
export default async function ServicesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const services = await api.kubernetes.services.getServices();
const context = await getSelectedKubernetesContextAsync(searchParams);
const services =
context.status === "unavailable" ? [] : await api.kubernetes.services.getServices({ contextId: context.contextId });
const tServices = await getScopedI18n("kubernetes.services");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tServices("label")}</Title>
<ServicesTable initialServices={services} />
<ServicesTable contextId={context.contextId} initialServices={services} />
</Stack>
</>
);
@@ -17,6 +17,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface ServicesTableComponentProps {
contextId: string;
initialServices: RouterOutputs["kubernetes"]["services"]["getServices"];
}
@@ -61,15 +62,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.services">): MRT
},
];
export function ServicesTable(initialData: ServicesTableComponentProps) {
export function ServicesTable({ contextId, initialServices }: ServicesTableComponentProps) {
const tServices = useScopedI18n("kubernetes.services");
const { data } = clientApi.kubernetes.services.getServices.useQuery(undefined, {
initialData: initialData.initialServices,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.services.getServices.useQuery(
{ contextId },
{
initialData: initialServices,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import type { KubernetesContextSearchParams } from "../kubernetes-context";
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
export default async function VolumesPage() {
export default async function VolumesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound();
}
const volumes = await api.kubernetes.volumes.getVolumes();
const context = await getSelectedKubernetesContextAsync(searchParams);
const volumes =
context.status === "unavailable" ? [] : await api.kubernetes.volumes.getVolumes({ contextId: context.contextId });
const tVolumes = await getScopedI18n("kubernetes.volumes");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tVolumes("label")}</Title>
<VolumesTable initialVolumes={volumes} />
<VolumesTable contextId={context.contextId} initialVolumes={volumes} />
</Stack>
</>
);
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime);
interface VolumesTableComponentProps {
contextId: string;
initialVolumes: RouterOutputs["kubernetes"]["volumes"]["getVolumes"];
}
@@ -66,15 +67,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.volumes">): MRT_
},
];
export function VolumesTable(initialData: VolumesTableComponentProps) {
export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponentProps) {
const tVolumes = useScopedI18n("kubernetes.volumes");
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(undefined, {
initialData: initialData.initialVolumes,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
{ contextId },
{
initialData: initialVolumes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
data,
@@ -2,7 +2,11 @@
import dynamic from "next/dynamic";
export const ClientSideTerminalComponent = dynamic(
interface ClientSideTerminalComponentProps {
focusTimestamp?: number;
}
export const ClientSideTerminalComponent = dynamic<ClientSideTerminalComponentProps>(
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
{
ssr: false,
@@ -29,12 +29,25 @@ export async function generateMetadata() {
};
}
export default async function LogsManagementPage() {
interface LogsManagementPageProps {
searchParams: Promise<{ focus?: string | string[] }>;
}
export default async function LogsManagementPage({ searchParams }: LogsManagementPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("other-view-logs")) {
notFound();
}
const focus = (await searchParams).focus;
let focusTimestamp: number | undefined;
if (typeof focus === "string") {
const parsedFocusTimestamp = Number(focus);
if (Number.isSafeInteger(parsedFocusTimestamp) && parsedFocusTimestamp > 0) {
focusTimestamp = parsedFocusTimestamp;
}
}
return (
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
<Group justify="space-between" align="center" wrap="nowrap">
@@ -45,7 +58,7 @@ export default async function LogsManagementPage() {
</Group>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent />
<ClientSideTerminalComponent focusTimestamp={focusTimestamp} />
</Box>
</LogContextProvider>
);
@@ -7,25 +7,163 @@ import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { clientApi } from "@homarr/api/client";
import type { LoggerMessage, LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { LOG_HISTORY_MAX_ENTRIES, logLevels } from "@homarr/core/infrastructure/logs/constants";
import { useI18n } from "@homarr/translation/client";
import { useLogContext } from "./log-context";
import classes from "./terminal.module.css";
export const TerminalComponent = () => {
const ALL_LOG_LEVELS = [...logLevels];
const TERMINAL_SCROLLBACK_LINES = LOG_HISTORY_MAX_ENTRIES * 20;
const getFocusMarker = (
messages: LoggerMessage[],
visibleMessages: LoggerMessage[],
focusTimestamp: number | undefined,
markerText: string,
expiredMarkerText: string,
) => {
if (focusTimestamp === undefined) return null;
const oldestTimestamp = messages[0]?.timestamp.getTime();
if (oldestTimestamp === undefined || focusTimestamp < oldestTimestamp) {
return { index: 0, text: expiredMarkerText };
}
return {
index: visibleMessages.findLastIndex((message) => message.timestamp.getTime() <= focusTimestamp) + 1,
text: markerText,
};
};
const findMarkerLine = (terminal: Terminal, linesFromEnd: number) => {
const buffer = terminal.buffer.active;
const logicalLineStarts: number[] = [];
const cursorLine = buffer.baseY + buffer.cursorY;
for (let index = 0; index <= cursorLine; index++) {
const line = buffer.getLine(index);
if (!line) continue;
if (!line.isWrapped) {
logicalLineStarts.push(index);
}
}
return logicalLineStarts.at(-linesFromEnd) ?? null;
};
const renderMessages = ({
terminal,
messages,
activeLevels,
focusTimestamp,
markerText,
expiredMarkerText,
}: {
terminal: Terminal;
messages: LoggerMessage[];
activeLevels: LogLevel[];
focusTimestamp: number | undefined;
markerText: string;
expiredMarkerText: string;
}) => {
const activeLevelSet = new Set(activeLevels);
const visibleMessages = messages.filter((message) => activeLevelSet.has(message.level));
const focusMarker = getFocusMarker(messages, visibleMessages, focusTimestamp, markerText, expiredMarkerText);
const output: string[] = [];
let markerOutputIndex: number | null = null;
visibleMessages.forEach((message, index) => {
if (focusMarker?.index === index) {
markerOutputIndex = output.length;
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
}
output.push(message.message);
});
if (focusMarker?.index === visibleMessages.length) {
markerOutputIndex = output.length;
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
}
let markerLinesFromEnd: number | null = null;
if (markerOutputIndex !== null) {
markerLinesFromEnd =
1 + output.slice(markerOutputIndex).reduce((lineCount, line) => lineCount + line.split(/\r?\n/).length, 0);
}
terminal.write(`\x1bc${output.join("\r\n")}${output.length > 0 ? "\r\n" : ""}`, () => {
if (markerLinesFromEnd === null) {
terminal.scrollToBottom();
return;
}
const markerLine = findMarkerLine(terminal, markerLinesFromEnd);
if (markerLine !== null) {
terminal.scrollToLine(markerLine);
}
});
};
interface TerminalComponentProps {
focusTimestamp?: number;
}
export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels, fontSize } = useLogContext();
const t = useI18n();
const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
const messagesRef = useRef<LoggerMessage[]>([]);
const initialFontSizeRef = useRef(fontSize);
const activeLevelsRef = useRef(activeLevels);
const markerTextRef = useRef("");
const expiredMarkerTextRef = useRef("");
const focusTimestampRef = useRef(focusTimestamp);
activeLevelsRef.current = activeLevels;
markerTextRef.current = t("log.context.widgetError");
expiredMarkerTextRef.current = t("log.context.expiredWidgetError");
focusTimestampRef.current = focusTimestamp;
const redrawTerminal = () => {
const terminal = terminalRef.current;
if (!terminal) return;
renderMessages({
terminal,
messages: messagesRef.current,
activeLevels: activeLevelsRef.current,
focusTimestamp: focusTimestampRef.current,
markerText: markerTextRef.current,
expiredMarkerText: expiredMarkerTextRef.current,
});
};
clientApi.log.subscribe.useSubscription(
{
levels: activeLevels,
levels: ALL_LOG_LEVELS,
},
{
onData(data) {
terminalRef.current?.writeln(data.message);
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
onData(event) {
if (event.type === "history") {
messagesRef.current = event.messages.slice(-LOG_HISTORY_MAX_ENTRIES);
redrawTerminal();
return;
}
messagesRef.current.push(event.message);
if (messagesRef.current.length > LOG_HISTORY_MAX_ENTRIES) {
messagesRef.current.shift();
}
if (activeLevelsRef.current.includes(event.message.level)) {
terminalRef.current?.writeln(event.message.message);
}
},
onError(err) {
alert(err);
@@ -44,22 +182,26 @@ export const TerminalComponent = () => {
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize,
fontSize: initialFontSizeRef.current,
scrollback: TERMINAL_SCROLLBACK_LINES,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
setTimeout(() => {
const fitTimeout = window.setTimeout(() => {
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
redrawTerminal();
});
return () => {
window.clearTimeout(fitTimeout);
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
};
}, []);
@@ -69,5 +211,9 @@ export const TerminalComponent = () => {
fitAddonRef.current?.fit();
}, [fontSize]);
useEffect(() => {
redrawTerminal();
}, [activeLevels, focusTimestamp]);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
};
@@ -1,17 +1,43 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback } from "react";
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
import { useCallback, useId } from "react";
import {
Badge,
Box,
Button,
Card,
Checkbox,
Collapse,
Divider,
Group,
SegmentedControl,
Stack,
Text,
Tooltip,
Transition,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconShieldExclamation } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissionKeys } from "@homarr/definitions";
import { objectEntries, objectKeys } from "@homarr/common";
import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixState } from "@homarr/definitions";
import {
getPermissionsWithChildren,
groupPermissionKeys,
isCapabilityImpliedByLevel,
matrixStateToPermissions,
permissionMatrix,
permissionsToMatrixState,
} from "@homarr/definitions";
import { createFormContext } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export type PermissionLabels = Partial<Record<GroupPermissionKey, string>>;
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
interface PermissionFormProps {
@@ -19,27 +45,17 @@ interface PermissionFormProps {
}
export const PermissionForm = ({ children, initialPermissions }: PropsWithChildren<PermissionFormProps>) => {
// Mantine compares each field against the values snapshot, so toggling a control back to its
// saved value clears the dirty state on its own - no manual bookkeeping needed here.
const form = useForm({
initialValues: groupPermissionKeys.reduce((acc, key) => {
acc[key] = initialPermissions.includes(key);
return acc;
}, {} as FormType),
onValuesChange(values) {
const currentKeys = objectEntries(values)
.filter(([_key, value]) => Boolean(value))
.map(([key]) => key);
if (
currentKeys.every((key) => initialPermissions.includes(key)) &&
initialPermissions.every((key) => currentKeys.includes(key))
) {
form.resetDirty(); // Reset dirty state if all keys are the same as initial
}
},
});
return (
<form>
<form onSubmit={(event) => event.preventDefault()}>
<FormProvider form={form}>{children}</FormProvider>
</form>
);
@@ -47,15 +63,329 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
type FormType = Record<GroupPermissionKey, boolean>;
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
const form = useFormContext();
type MatrixForm = ReturnType<typeof useFormContext>;
const props = form.getInputProps(name, {
withError: false,
type: "checkbox",
const emptyMatrixState = (): PermissionMatrixState =>
objectKeys(permissionMatrix).reduce((acc, category) => {
acc[category] = { level: 0, capabilities: [] };
return acc;
}, {} as PermissionMatrixState);
const matrixStateOf = (values: FormType) => permissionsToMatrixState(groupPermissionKeys.filter((key) => values[key]));
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number =>
matrixStateOf(values)[category].level;
export const hasMatrixCapability = (
values: FormType,
category: PermissionMatrixCategory,
capability: GroupPermissionKey,
): boolean => matrixStateOf(values)[category].capabilities.includes(capability);
export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => {
const targetKeys = new Set(
matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level, capabilities: [] } }),
);
permissionMatrix[category].levels.forEach((key) => {
form.setFieldValue(key, targetKeys.has(key));
});
// Capability keys are deliberately left untouched: a level that implies one already covers it,
// and an explicitly granted capability must survive lowering the level again.
};
return <Switch {...props} />;
export const applyMatrixCapability = (form: MatrixForm, capability: GroupPermissionKey, granted: boolean) => {
form.setFieldValue(capability, granted);
};
const presets = {
viewer: {
board: { level: 1, capabilities: [] },
app: { level: 0, capabilities: ["app-use-all"] },
integration: { level: 1, capabilities: [] },
media: { level: 1, capabilities: [] },
},
editor: {
board: { level: 2, capabilities: ["board-create"] },
app: { level: 1, capabilities: ["app-use-all"] },
integration: { level: 2, capabilities: ["integration-create"] },
"search-engine": { level: 1, capabilities: [] },
media: { level: 1, capabilities: ["media-upload"] },
},
admin: { admin: { level: 1, capabilities: [] } },
} satisfies Record<string, Partial<PermissionMatrixState>>;
export type PermissionPreset = keyof typeof presets;
const presetToFormState = (preset: PermissionPreset): FormType => {
const presetState: Partial<PermissionMatrixState> = presets[preset];
const state = objectKeys(permissionMatrix).reduce((acc, category) => {
acc[category] = presetState[category] ?? { level: 0, capabilities: [] };
return acc;
}, {} as PermissionMatrixState);
const enabledKeys = new Set(matrixStateToPermissions(state));
return groupPermissionKeys.reduce((acc, key) => {
acc[key] = enabledKeys.has(key);
return acc;
}, {} as FormType);
};
export const applyPreset = (form: MatrixForm, preset: PermissionPreset) => {
form.setValues(presetToFormState(preset));
};
export const getActivePreset = (values: FormType): PermissionPreset | null => {
return (
objectKeys(presets).find((preset) => {
const presetValues = presetToFormState(preset);
return groupPermissionKeys.every((key) => presetValues[key] === values[key]);
}) ?? null
);
};
/**
* Short, scannable label for a level. Categories with a single level use their permission label
* instead, so the control reads "No access | View logs" rather than "No access | Enabled".
*/
const levelLabelKey = (key: GroupPermissionKey) => {
if (key.endsWith("-view-all")) return "view" as const;
if (key.endsWith("-use-all")) return "use" as const;
if (key.endsWith("-interact-all")) return "interact" as const;
if (key.endsWith("-modify-all")) return "modify" as const;
if (key.endsWith("-full-all")) return "full" as const;
return null;
};
interface MatrixImpliedHintProps {
category: PermissionMatrixCategory;
permissionLabels: PermissionLabels;
}
export const MatrixImpliedHint = ({ category, permissionLabels }: MatrixImpliedHintProps) => {
const form = useFormContext();
const tPermissions = useScopedI18n("group.permission");
const level = getMatrixLevel(form.getValues(), category);
if (level === 0) {
return null;
}
const checkedKeys: GroupPermissionKey[] = permissionMatrix[category].levels.slice(0, level);
const implied = getPermissionsWithChildren(checkedKeys).filter((key) => !checkedKeys.includes(key));
if (implied.length === 0) {
return null;
}
const labels = implied.map((key) => permissionLabels[key] ?? key);
return (
<Text size="xs" c="dimmed">
{tPermissions("matrix.impliedHint", { permissions: labels.join(", ") })}
</Text>
);
};
interface MatrixRowProps {
category: PermissionMatrixCategory;
permissionLabels: PermissionLabels;
permissionDescriptions: PermissionLabels;
}
export const MatrixRow = ({ category, permissionLabels, permissionDescriptions }: MatrixRowProps) => {
const form = useFormContext();
const tPermissions = useScopedI18n("group.permission");
const values = form.getValues();
const level = getMatrixLevel(values, category);
const isDanger = category === "admin";
const disabledByAdmin = !isDanger && getMatrixLevel(values, "admin") > 0;
const { levels, capabilities } = permissionMatrix[category];
const hasSingleLevel = levels.length === 1;
const data = [
{ value: "0", label: tPermissions("matrix.none") },
...levels.map((key, index) => {
const shortKey = hasSingleLevel ? null : levelLabelKey(key);
return {
value: String(index + 1),
label: shortKey === null ? (permissionLabels[key] ?? key) : tPermissions(`matrix.level.${shortKey}`),
};
}),
];
const capabilityStates = capabilities.map((capability) => ({
capability,
checked: hasMatrixCapability(values, category, capability),
implied: isCapabilityImpliedByLevel(category, capability, level),
}));
const selectedKey = level === 0 ? undefined : levels[level - 1];
// With no access level but a capability granted, describing the row as "no access" would
// contradict the checkbox next to it, so the granted capability describes the row instead.
const describedKey = selectedKey ?? capabilityStates.find(({ checked }) => checked)?.capability;
const description =
describedKey === undefined ? tPermissions("matrix.noneDescription") : (permissionDescriptions[describedKey] ?? "");
return (
<Stack gap={6}>
<Group gap="sm" wrap="wrap" align="center" justify="space-between">
<Group gap={6} wrap="nowrap">
{isDanger && <IconShieldExclamation size={18} color="var(--mantine-color-red-8)" />}
<Text fw={500} c={isDanger ? "red.8" : undefined}>
{tPermissions(`${category}.title`)}
</Text>
</Group>
<Group gap="md" wrap="wrap" align="center">
<SegmentedControl
size="sm"
data={data}
value={String(level)}
disabled={disabledByAdmin}
aria-label={tPermissions(`${category}.title`)}
onChange={(value) => applyMatrixLevel(form, category, Number(value))}
/>
{/* Fixed-width column so every level control lines up, whatever capabilities a row has. */}
<Box w={{ base: "auto", sm: 230 }}>
<Stack gap={4}>
{capabilityStates.map(({ capability, checked, implied }) => (
<Tooltip
key={capability}
label={tPermissions("matrix.capabilityIncluded", { level: data[level]?.label ?? "" })}
disabled={!implied}
>
<Checkbox
size="sm"
label={permissionLabels[capability] ?? capability}
checked={checked}
disabled={disabledByAdmin || implied}
onChange={(event) => applyMatrixCapability(form, capability, event.currentTarget.checked)}
/>
</Tooltip>
))}
</Stack>
</Box>
</Group>
</Group>
<Text size="xs" c="dimmed">
{description}
</Text>
{disabledByAdmin && (
<Text size="xs" c="dimmed">
{tPermissions("matrix.grantedViaAdmin")}
</Text>
)}
<MatrixImpliedHint category={category} permissionLabels={permissionLabels} />
</Stack>
);
};
interface PermissionMatrixProps {
permissionLabels: PermissionLabels;
permissionDescriptions: PermissionLabels;
}
export const PermissionMatrix = ({ permissionLabels, permissionDescriptions }: PermissionMatrixProps) => (
<Card withBorder p="md">
<Stack gap="md">
{objectKeys(permissionMatrix).map((category, index) => (
<Stack key={category} gap="md">
{index > 0 && <Divider />}
<MatrixRow
category={category}
permissionLabels={permissionLabels}
permissionDescriptions={permissionDescriptions}
/>
</Stack>
))}
</Stack>
</Card>
);
export const PresetButtons = () => {
const form = useFormContext();
const tPermissions = useScopedI18n("group.permission");
const activePreset = getActivePreset(form.getValues());
return (
<Group gap="xs" wrap="wrap">
<Text size="sm" fw={500}>
{tPermissions("matrix.preset.label")}
</Text>
{(["viewer", "editor", "admin"] as const).map((preset) => (
<Tooltip key={preset} label={tPermissions(`matrix.preset.${preset}Description`)}>
<Button
size="xs"
variant={activePreset === preset ? "filled" : "default"}
onClick={() => applyPreset(form, preset)}
>
{tPermissions(`matrix.preset.${preset}`)}
</Button>
</Tooltip>
))}
{activePreset === null && <Badge variant="light">{tPermissions("matrix.preset.custom")}</Badge>}
</Group>
);
};
interface EffectivePermissionPreviewProps {
permissionLabels: PermissionLabels;
}
export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermissionPreviewProps) => {
const form = useFormContext();
const tPermissions = useScopedI18n("group.permission");
const [opened, { toggle }] = useDisclosure(false);
const panelId = useId();
const values = form.getValues();
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
const effective = getPermissionsWithChildren(checkedKeys);
const grouped = objectKeys(permissionMatrix)
.map((category) => ({
category,
keys: effective.filter((key) => key === category || key.startsWith(`${category}-`)),
}))
.filter(({ keys }) => keys.length > 0);
return (
<Card withBorder p="md">
<UnstyledButton onClick={toggle} w="100%" aria-expanded={opened} aria-controls={panelId}>
<Group justify="space-between" wrap="nowrap">
<Stack gap={0}>
<Text fw={500}>{tPermissions("matrix.effective.title")}</Text>
<Text size="xs" c="dimmed">
{tPermissions("matrix.effective.description")}
</Text>
</Stack>
<Badge variant="light">{effective.length}</Badge>
</Group>
</UnstyledButton>
<Collapse id={panelId} expanded={opened}>
<Stack gap="xs" mt="sm">
{grouped.length === 0 ? (
<Text size="sm" c="dimmed">
{tPermissions("matrix.effective.empty")}
</Text>
) : (
grouped.map(({ category, keys }) => (
<Group key={category} gap="xs" wrap="wrap" align="flex-start">
<Text size="sm" fw={500}>
{tPermissions(`${category}.title`)}
</Text>
<Badge size="sm" variant="light">
{keys.length}
</Badge>
<Text size="sm" c="dimmed">
{keys.map((key) => permissionLabels[key] ?? key).join(", ")}
</Text>
</Group>
))
)}
</Stack>
</Collapse>
</Card>
);
};
interface SaveAffixProps {
@@ -80,8 +410,10 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
},
{
onSuccess: () => {
// Set new initial values for discard and reset dirty state
// setInitialValues moves the snapshot so Discard returns here, but only resetDirty clears
// the dirty flags that keep the unsaved-changes bar on screen.
form.setInitialValues(values);
form.resetDirty(values);
showSuccessNotification({
title: tNotification("success.title"),
message: tNotification("success.message"),
@@ -1,6 +1,5 @@
import React from "react";
import { notFound } from "next/navigation";
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -9,7 +8,14 @@ import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissions } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
import type { PermissionLabels } from "./_group-permission-form";
import {
EffectivePermissionPreview,
PermissionForm,
PermissionMatrix,
PresetButtons,
SaveAffix,
} from "./_group-permission-form";
interface GroupPermissionsPageProps {
params: Promise<{
@@ -26,27 +32,21 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
}
const group = await api.group.getById({ id: params.id });
const tPermissions = await getScopedI18n("group.permission");
const t = await getI18n();
const { permissionLabels, permissionDescriptions } = await buildPermissionTextsAsync();
return (
<Stack>
<Title>{t("management.page.group.setting.permissions.title")}</Title>
<PermissionForm initialPermissions={group.permissions}>
<Stack pos="relative">
{objectKeys(groupPermissions).map((group) => {
const isDanger = group === "admin";
<PresetButtons />
return (
<Stack key={group} gap="sm">
<Title order={2} c={isDanger ? "red.8" : undefined}>
{tPermissions(`${group}.title`)}
</Title>
<PermissionCard isDanger={isDanger} group={group} />
</Stack>
);
})}
<PermissionMatrix permissionLabels={permissionLabels} permissionDescriptions={permissionDescriptions} />
<EffectivePermissionPreview permissionLabels={permissionLabels} />
<SaveAffix groupId={group.id} />
</Stack>
@@ -55,66 +55,21 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
);
}
interface PermissionCardProps {
group: keyof typeof groupPermissions;
isDanger: boolean;
}
const buildPermissionTextsAsync = async () => {
const permissionLabels: PermissionLabels = {};
const permissionDescriptions: PermissionLabels = {};
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
const t = await getScopedI18n(`group.permission.${group}.item`);
const item = groupPermissions[group];
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]);
for (const category of objectKeys(groupPermissions)) {
const tItem = await getScopedI18n(`group.permission.${category}.item`);
const item = groupPermissions[category];
const suffixes = typeof item !== "boolean" ? item : (["admin"] as const);
return (
<Card
p="md"
style={{
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
}}
>
<Stack gap="sm">
{permissions.map((permission, index) => (
<React.Fragment key={permission}>
<PermissionRow
name={createGroupPermissionKey(group, permission)}
label={t(`${permission}.label`)}
description={t(`${permission}.description`)}
/>
{index < permissions.length - 1 && (
<CardSection>
<Divider />
</CardSection>
)}
</React.Fragment>
))}
</Stack>
</Card>
);
};
const createGroupPermissionKey = (group: keyof typeof groupPermissions, permission: string): GroupPermissionKey => {
if (typeof groupPermissions[group] === "boolean") {
return group as GroupPermissionKey;
suffixes.forEach((suffix) => {
const key = (typeof item !== "boolean" ? `${category}-${suffix}` : "admin") as GroupPermissionKey;
permissionLabels[key] = tItem(`${suffix}.label`);
permissionDescriptions[key] = tItem(`${suffix}.description`);
});
}
return `${group}-${permission}` as GroupPermissionKey;
};
interface PermissionRowProps {
name: GroupPermissionKey;
label: string;
description: string;
}
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
return (
<Group justify="space-between" align="center" wrap="nowrap">
<Stack gap={0}>
<Text fw={500}>{label}</Text>
<Text c="gray.5">{description}</Text>
</Stack>
<PermissionSwitch name={name} />
</Group>
);
return { permissionLabels, permissionDescriptions };
};
@@ -1,46 +1,6 @@
import { redirect } from "next/navigation";
import { Alert, Button, Stack } from "@mantine/core";
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
import { permanentRedirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
import { env } from "~/env";
export default async function WorkshopPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const t = await getScopedI18n("workshop");
const { workshopWebUrl } = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
return (
<ManagePageLayout
title={t("title")}
primaryAction={
<Button
component="a"
href={workshopWebUrl}
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<IconExternalLink size={16} />}
>
{t("openCommunity")}
</Button>
}
>
<Stack gap="lg">
<Alert icon={<IconInfoCircle size={18} />} title={t("manageSubmissionsTitle")} color="blue">
{t("manageSubmissionsDescription")}
</Alert>
<WorkshopInstaller />
</Stack>
</ManagePageLayout>
);
/** The Workshop now lives as a tab of the Custom Widgets page. */
export default function WorkshopPage(): never {
permanentRedirect("/manage/custom-widgets/workshop");
}
+10 -6
View File
@@ -4,7 +4,7 @@ import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import BetterSqlite3 from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
@@ -18,6 +18,9 @@ import { env } from "@homarr/common/env";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { db } from "@homarr/db";
import type { Database } from "@homarr/db";
import { applyCustomMigrationsAsync } from "@homarr/db/migrations/custom";
import { schema } from "@homarr/db/schema";
import { findMigrationsFolder } from "../shared";
@@ -120,7 +123,7 @@ const assertArchiveSizeLimits = (zip: AdmZip) => {
}
};
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
const reEncryptSecrets = (tempDb: InstanceType<typeof BetterSqlite3>, importedKeyHex: string) => {
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
if (importedKeyHex === currentKeyHex) return;
@@ -172,7 +175,7 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex:
transaction();
};
const getHomeBoardName = (tempDb: InstanceType<typeof Database>): string | null => {
const getHomeBoardName = (tempDb: InstanceType<typeof BetterSqlite3>): string | null => {
const row = tempDb
.prepare(
`SELECT "name" FROM (
@@ -234,7 +237,7 @@ export async function POST(req: Request) {
restoreInProgress = true;
let tempDirectory: string | null = null;
let tempDb: InstanceType<typeof Database> | null = null;
let tempDb: InstanceType<typeof BetterSqlite3> | null = null;
try {
let formData: FormData;
@@ -285,9 +288,10 @@ export async function POST(req: Request) {
}
fs.writeFileSync(tempPath, dbEntry.getData());
tempDb = new Database(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
tempDb = new BetterSqlite3(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING, schema });
migrate(drizzleDb, { migrationsFolder });
await applyCustomMigrationsAsync(drizzleDb as unknown as Database);
const metadataEntry = zip.getEntry("metadata.json");
if (!metadataEntry) {
+2 -3
View File
@@ -1,12 +1,11 @@
import fs from "fs";
import path from "path";
export const findMigrationsFolder = () => {
const cwd = process.cwd();
export const findMigrationsFolder = (cwd = process.cwd()) => {
const migrationsFolderCandidates = [
path.join(cwd, "db/migrations/sqlite"),
path.join(cwd, "packages/db/migrations/sqlite"),
path.resolve(cwd, "../../db/migrations/sqlite"),
path.resolve(cwd, "../../packages/db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
Loaded 100 of 416 files, more files were not shown because too many files have changed in this diff. Show more