Compare commits

...
Author SHA1 Message Date
ajnart 37ef86f32e Embed integration editing in widget tab 2026-08-19 09:46:58 +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
237 changed files with 8809 additions and 1719 deletions

No files matched your search

+60 -34
View File
@@ -105,19 +105,20 @@ jobs:
container: container:
name: Container and E2E name: Container and E2E
needs: fast-gate needs: [fast-gate, preview-amd64]
if: | if: |
github.event_name == 'merge_group' || !cancelled() && needs.fast-gate.result == 'success' &&
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) || github.event_name == 'merge_group' ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish')) (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 runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
permissions: permissions:
contents: read contents: read
packages: write packages: read
outputs:
digest: ${{ steps.preview-build.outputs.digest }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
if: "!env.ACT" if: "!env.ACT"
@@ -132,8 +133,18 @@ jobs:
if: "!env.ACT" if: "!env.ACT"
uses: docker/setup-buildx-action@v4 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 - name: Build container once
if: "!env.ACT" if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
context: . context: .
@@ -148,8 +159,8 @@ jobs:
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }} org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
cache-from: type=gha,scope=homarr-linux-amd64 cache-from: type=gha,scope=homarr-e2e
cache-to: type=gha,scope=homarr-linux-amd64,mode=max cache-to: type=gha,scope=homarr-e2e,mode=max
secrets: | secrets: |
TURBO_API=${{ secrets.TURBO_API }} TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }} TURBO_TEAM=${{ secrets.TURBO_TEAM }}
@@ -190,7 +201,10 @@ jobs:
run: pnpm exec playwright install-deps chromium run: pnpm exec playwright install-deps chromium
- name: Run E2E tests - 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 - name: Upload E2E artifacts
if: failure() && !env.ACT if: failure() && !env.ACT
@@ -200,29 +214,44 @@ jobs:
path: e2e/test-results/ path: e2e/test-results/
retention-days: 7 retention-days: 7
- name: Log in to GHCR for preview publication preview-amd64:
if: | name: Build amd64 preview image
!env.ACT && needs: fast-gate
( 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 == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') (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 uses: docker/login-action@v4
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} 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 id: preview-build
if: | if: "!env.ACT"
!env.ACT &&
(
(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')
)
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
context: . context: .
@@ -230,16 +259,13 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
outputs: type=image,"name=${{ env.PREVIEW_IMAGE }}",push-by-digest=true,name-canonical=true,push=true 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-from: type=gha,scope=homarr-linux-amd64
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
secrets: | secrets: |
TURBO_API=${{ secrets.TURBO_API }} TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }} TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} 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: preview-arm64:
name: Build arm64 preview image name: Build arm64 preview image
needs: fast-gate needs: fast-gate
@@ -294,7 +320,7 @@ jobs:
publish-preview: publish-preview:
name: Publish multi-platform preview image name: Publish multi-platform preview image
needs: [container, preview-arm64] needs: [preview-amd64, preview-arm64]
if: | if: |
( (
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
@@ -345,7 +371,7 @@ jobs:
- name: Publish preview manifest - name: Publish preview manifest
if: "!env.ACT" if: "!env.ACT"
env: env:
AMD64_DIGEST: ${{ needs.container.outputs.digest }} AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }} ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
run: | run: |
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \ docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
@@ -1,5 +1,6 @@
permissions: permissions:
statuses: write statuses: write
contents: read
on: on:
pull_request: pull_request:
@@ -16,12 +17,20 @@ jobs:
env: env:
GH_TOKEN: ${{secrets.GITHUB_TOKEN}} GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
run: | run: |
gh api \ for attempt in 1 2 3 4 5; do
--method POST \ if gh api \
-H "Accept: application/vnd.github+json" \ --method POST \
-H "X-GitHub-Api-Version: 2022-11-28" \ -H "Accept: application/vnd.github+json" \
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \ -H "X-GitHub-Api-Version: 2022-11-28" \
-f 'state=success' \ /repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \ -f 'state=success' \
-f 'description=Skipped for non renovate branches' \ -f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
-f 'context=renovate/stability-days' -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
@@ -58,11 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker ## Docker
| Environment Variable | Description | Possible values | Default | | Environment Variable | Description | Possible values | Default |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - | | `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_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - | | `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_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` | - | | `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 ## 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. 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 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, 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.
so Homarr can interact with the Docker service.
In Docker Compose, you can simply add this line: 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 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 ### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system. 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: Each container row has an action menu with the following options:
- **View logs** - opens a dedicated logs page with a live terminal - **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. 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. 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 #### 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. 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. 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] :::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. 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: Before deploying Homarr in Kubernetes, ensure the following prerequisites are met:
- Kubernetes (Version >= 1.24.0) - 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) - RBAC Enabled (Required for Homarr Helm chart)
### Configuring RBAC in 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. - 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 ### Homarr Dashboard Overview
Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes: Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes:
+28
View File
@@ -86,6 +86,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. 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 ## Partial integration failures
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
+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%" }} style={{ maxWidth: "100%" }}
/> />
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
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. 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 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. 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. 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. 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 #### Preview
@@ -59,6 +59,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. 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. 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 ## Managing permissions
On the edit page of an integration, you can manage which users / groups can On the edit page of an integration, you can manage which users / groups can
@@ -7,20 +7,15 @@ hide_title: true
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; 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 { 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 { dockerContainersWidget } from ".";
import { dockerIntegration } from "@site/docs/integrations/docker";
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} /> <WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
This widget displays the resource usage of your Docker containers and allows you to manage them. This widget displays the resource usage of your Docker containers and allows you to manage them.
:::info :::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 ### 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} /> <img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: dockerIntegration,
},
]}
/>
### Interactions ### 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. 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", path: "../../widgets/docker-containers",
configuration: { configuration: {
items: [ 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", name: "Columns to show",
description: "Select which columns are visible in the table", 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"]} /> <WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget shows active sessions across your media servers. 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. 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. If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
+7 -1
View File
@@ -1266,9 +1266,15 @@
"properties": { "properties": {
"boardId": { "boardId": {
"type": "string" "type": "string"
},
"name": {
"type": "string"
},
"layoutId": {
"type": "string"
} }
}, },
"required": ["boardId"], "required": ["boardId", "name", "layoutId"],
"additionalProperties": false "additionalProperties": false
} }
} }
+1
View File
@@ -66,6 +66,7 @@
"@mantine/form": "catalog:", "@mantine/form": "catalog:",
"@mantine/hooks": "catalog:", "@mantine/hooks": "catalog:",
"@mantine/modals": "catalog:", "@mantine/modals": "catalog:",
"@mantine/notifications": "catalog:",
"@mantine/tiptap": "catalog:", "@mantine/tiptap": "catalog:",
"@modelcontextprotocol/server": "catalog:", "@modelcontextprotocol/server": "catalog:",
"@rexa-developer/tiks": "catalog:", "@rexa-developer/tiks": "catalog:",
@@ -8,6 +8,8 @@ import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; 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 { import {
getBoardLaneColumnCount, getBoardLaneColumnCount,
getInitialBoardLogicalHeight, getInitialBoardLogicalHeight,
@@ -64,6 +66,8 @@ export const ClientBoard = () => {
<BoardAdvancedFocusProvider> <BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo /> <BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider> <BoardSectionCollapseProvider>
<BoardGridPortalHost> <BoardGridPortalHost>
<ScaledBoardCanvas <ScaledBoardCanvas
@@ -1,18 +1,15 @@
"use client"; "use client";
import { Group, Menu } from "@mantine/core"; import { useEffect, useRef } from "react";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-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 { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions"; import { useBoardAddActions } from "~/components/board/use-board-add-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { UniversalCreateModal } from "~/components/create/universal-create-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() { export default function BoardEditActions() {
@@ -20,60 +17,41 @@ export default function BoardEditActions() {
} }
const AddMenu = () => { const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); const { openModal } = useModalAction(UniversalCreateModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const t = useI18n(); const t = useI18n();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const handledInitialAdd = useRef(false);
const handleSelectItem = () => { useEffect(() => {
openItemSelectModal({ boardId: board.id }); if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
}; handledInitialAdd.current = true;
addWidget();
const handleSelectApp = () => { const nextSearchParams = new URLSearchParams(searchParams.toString());
openAppSelectModal({ nextSearchParams.delete("add");
onSelect: (app) => { const query = nextSearchParams.toString();
createItem({ router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
kind: "app", }, [addWidget, board.id, pathname, router, searchParams]);
options: { appId: app.id },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({});
};
return ( return (
<Menu position="bottom-end"> <HeaderButton
<Menu.Target> aria-label={t("board.action.addContent")}
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}> onClick={() =>
<Group gap={4} wrap="nowrap"> openModal({
<IconPlus stroke={1.5} /> boardActions: {
<IconChevronDown color="gray" size={16} /> widget: addWidget,
</Group> app: addApp,
</HeaderButton> integration: canConnectService ? connectService : undefined,
</Menu.Target> container: addContainer,
<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}> <IconPlus stroke={1.5} />
{t("app.action.add")} </HeaderButton>
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
); );
}; };
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { import {
IconDeviceFloppy, IconDeviceFloppy,
IconLayoutBoard, IconLayoutBoard,
@@ -98,6 +99,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
latestBoardRef.current = board; latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({ const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onMutate(boardToSave) {
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
},
onSuccess() { onSuccess() {
showSuccessNotification({ showSuccessNotification({
title: t("notification.success.title"), title: t("notification.success.title"),
@@ -54,7 +54,12 @@ export default async function ManageBoardsPage() {
floatingPrimaryAction={canCreateBoards} floatingPrimaryAction={canCreateBoards}
> >
{boards.length === 0 && ( {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 && ( {boards.length > 0 && (
<TourTarget id="manage-boards-list"> <TourTarget id="manage-boards-list">
@@ -1,12 +1,14 @@
"use client"; "use client";
import { Avatar, Badge, Button, Group, 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 { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; 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 { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions"; import { CustomWidgetRowActions } from "./_custom-widget-actions";
@@ -41,75 +43,92 @@ type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetRow({ widget }: { widget: WidgetDef }) { function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget"); const t = useScopedI18n("customWidget");
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
const origins = [...new Set(widget.sources.map((source) => source.origin))]; const origins = [...new Set(widget.sources.map((source) => source.origin))];
return ( return (
<ManageCollectionItem <>
leading={ <ManageCollectionItem
<Avatar leading={
size={40} <Avatar
radius="sm" size={40}
src={widget.iconUrl} radius="sm"
color={widget.valid ? "pink" : "red"} src={widget.iconUrl}
styles={{ image: { objectFit: "contain" } }} color={widget.valid ? "pink" : "red"}
alt="" styles={{ image: { objectFit: "contain" } }}
> alt=""
<IconApi size={20} stroke={1.5} /> >
</Avatar> <IconApi size={20} stroke={1.5} />
} </Avatar>
title={ }
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}> title={
{widget.name} <Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
</Text> {widget.name}
}
badges={<CustomWidgetStatusBadges widget={widget} />}
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> </Text>
{origins.map((origin) => ( }
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}> badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />}
· {origin} 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> </Text>
))} {origins.map((origin) => (
</Group> <Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
} · {origin}
actions={ </Text>
<Group gap="xs" wrap="nowrap"> ))}
{widget.valid && ( </Group>
<Button }
component={Link} actions={
href={`/manage/custom-widgets/edit/${widget.id}`} <Group gap="xs" wrap="nowrap">
variant="default" {widget.valid && (
size="sm" <Button
leftSection={<IconPencil size={16} stroke={1.5} />} component={Link}
> href={`/manage/custom-widgets/edit/${widget.id}`}
{t("action.edit")} variant="default"
</Button> size="sm"
)} leftSection={<IconPencil size={16} stroke={1.5} />}
<CustomWidgetRowActions >
widget={{ {t("action.edit")}
id: widget.id, </Button>
name: widget.name, )}
enabled: widget.enabled, <CustomWidgetRowActions
valid: widget.valid, widget={{
migrationRequired: widget.migrationRequired, id: widget.id,
}} name: widget.name,
/> enabled: widget.enabled,
</Group> 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. */ /** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) { function CustomWidgetStatusBadges({
widget,
onConfigureSources,
}: {
widget: WidgetDef;
onConfigureSources(): void;
}) {
const t = useScopedI18n("customWidget"); const t = useScopedI18n("customWidget");
if (widget.migrationRequired) { if (widget.migrationRequired) {
@@ -157,9 +176,17 @@ function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
multiline multiline
maw={320} maw={320}
> >
<Badge color="yellow" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}> <UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })} <Badge
</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> </Tooltip>
)} )}
</Group> </Group>
@@ -42,7 +42,11 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
<Text size="md"> <Text size="md">
{t.rich("integration.testConnection.error.statusCode.otherDescription", { {t.rich("integration.testConnection.error.statusCode.otherDescription", {
statusCode: error.data.statusCode.toString(), 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> </Text>
) : ( ) : (
@@ -50,7 +54,11 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
{t.rich("integration.testConnection.error.statusCode.description", { {t.rich("integration.testConnection.error.statusCode.description", {
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`), reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
statusCode: error.data.statusCode.toString(), 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> </Text>
) )
@@ -66,7 +74,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
{t("integration.testConnection.error.common.cause.title")} {t("integration.testConnection.error.common.cause.title")}
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<pre style={{ whiteSpace: "pre-wrap" }}> <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{error.name}: {error.message} {error.name}: {error.message}
{"\n"} {"\n"}
{causeArray {causeArray
@@ -1,6 +1,8 @@
import type { RouterOutputs } from "@homarr/api"; 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 MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"]; export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>; export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useImperativeHandle, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react"; 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 { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types"; import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
interface EditIntegrationForm { interface EditIntegrationFormProps {
integration: RouterOutputs["integration"]["byId"]; 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( 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 t = useI18n();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind); const allSecretKinds = getAllSecretKindOptions(integration.kind);
@@ -67,17 +80,36 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
app: integration.app ?? null, 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 [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const errorRef = useRef<HTMLDivElement>(null);
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async ({ app, ...values }: FormType) => { const handleSubmitAsync = useCallback(
const url = hasUrlSecret async ({ app, ...values }: FormType) => {
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin setError(null);
: values.url; let url: string;
await mutateAsync( 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, id: integration.id,
...values, ...values,
url, url,
@@ -86,112 +118,130 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
value: secret.value === "" ? null : secret.value, value: secret.value === "" ? null : secret.value,
})), })),
appId: app?.id ?? null, 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({ // We do it this way as we are unable to send a typesafe error through onError
title: t("integration.page.edit.notification.success.title"), if (data?.error) {
message: t("integration.page.edit.notification.success.message"), setError(data.error);
}); showErrorNotification({
void utils.integration.invalidate(); title: t("integration.page.edit.notification.error.title"),
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); message: t("integration.page.edit.notification.error.message"),
}, });
onError: () => { requestAnimationFrame(() => errorRef.current?.focus());
showErrorNotification({ return false;
title: t("integration.page.edit.notification.error.title"), }
message: t("integration.page.edit.notification.error.message"),
}); 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 = const isInitialSecretKinds =
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) && initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
form.values.secrets.length === initialSecretsKinds.length; form.values.secrets.length === initialSecretsKinds.length;
return ( const formFields = (
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}> <Stack>
<Stack> <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : ( {hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)} )}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{allSecretKinds.length > 1 && ( {allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl <SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
defaultKinds={initialSecretsKinds} )}
secretKinds={allSecretKinds} {!isInitialSecretKinds
form={form} ? null
/> : form.values.secrets.map((secret, index) => (
)} <SecretCard
{!isInitialSecretKinds key={secret.kind}
? null secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
: form.values.secrets.map((secret, index) => ( onCancel={() =>
<SecretCard new Promise((resolve) => {
key={secret.kind} // When nothing changed, just close the secret card
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }} if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
onCancel={() => return resolve(true);
new Promise((resolve) => { }
// When nothing changed, just close the secret card openConfirmModal({
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) { title: t("integration.secrets.reset.title"),
return resolve(true); children: t("integration.secrets.reset.message"),
} onCancel: () => resolve(false),
openConfirmModal({ onConfirm: () => {
title: t("integration.secrets.reset.title"), form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
children: t("integration.secrets.reset.message"), resolve(true);
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) => (
<IntegrationSecretInput <IntegrationSecretInput
withAsterisk label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={kind} key={secret.kind}
kind={kind} kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)} {...form.getInputProps(`secrets.${index}.value`)}
/> />
))} </SecretCard>
{form.values.secrets.length === 0 && ( ))}
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}> {isInitialSecretKinds
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text> ? null
</Alert> : form.values.secrets.map(({ kind }, index) => (
)} <IntegrationSecretInput
</Stack> withAsterisk
</Fieldset> 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"> <Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations"> <Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")} {t("common.action.backToOverview")}
@@ -200,9 +250,15 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
{t("integration.testConnection.action.edit")} {t("integration.testConnection.action.edit")}
</Button> </Button>
</Group> </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>; type FormType = z.infer<typeof formSchema>;
@@ -19,6 +19,7 @@ import {
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react"; import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -49,10 +50,12 @@ interface NewIntegrationFormProps {
kind: IntegrationKind; kind: IntegrationKind;
initialUrl?: string; initialUrl?: string;
initialName?: string; initialName?: string;
onSuccess: () => void; onSuccess: (result?: CreatedIntegrationResult) => void;
onCancel?: () => void; onCancel?: () => void;
} }
export type CreatedIntegrationResult = Extract<RouterOutputs["integration"]["create"], { integration: unknown }>;
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
z.object({ z.object({
hasApp: z.boolean(), hasApp: z.boolean(),
@@ -67,12 +70,18 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const { data: session } = useSession(); const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false; 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) ?? ""; let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
if (hasUrlSecret) { if (hasUrlSecret) {
url = "http://localhost"; url = "http://localhost";
} }
const form = useZodForm(formSchema, { const form = useZodForm(validationSchema, {
initialValues: { initialValues: {
name: initialName ?? getIntegrationName(kind), name: initialName ?? getIntegrationName(kind),
url, url,
@@ -81,7 +90,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
value: "", value: "",
})), })),
attemptSearchEngineCreation: true, attemptSearchEngineCreation: true,
hasApp: true, hasApp: canCreateApps,
appHref: url, appHref: url,
appId: null, 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 ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url; : values.url;
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => { const onMutationSuccess = (
data: CreatedIntegrationResult | { error?: AnyMappedTestConnectionError } | undefined | void,
) => {
if (data && "error" in data && data.error) { if (data && "error" in data && data.error) {
setError(data.error); setError(data.error);
showErrorNotification({ showErrorNotification({
@@ -116,7 +127,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
message: t("integration.page.create.notification.success.message"), message: t("integration.page.create.notification.success.message"),
}); });
onSuccess(); onSuccess(data && "integration" in data ? data : undefined);
}; };
const onMutationError = () => { const onMutationError = () => {
@@ -131,13 +142,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
const app = hasApp const app = hasApp
? appId !== null ? appId !== null
? { id: appId } ? { id: appId }
: { : canCreateApps
name: values.name, ? {
href: hasCustomHref ? appHref : url, name: values.name,
iconUrl: getIconUrl(kind), href: hasCustomHref ? appHref : url,
description: null, iconUrl: getIconUrl(kind),
pingUrl: url, description: null,
} pingUrl: url,
}
: undefined
: undefined; : undefined;
await createIntegrationAsync( await createIntegrationAsync(
@@ -229,8 +242,10 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
checkboxInputProps.onChange(event); checkboxInputProps.onChange(event);
}); });
}} }}
label={t("integration.field.createApp.label")} label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
description={t("integration.field.createApp.description")} description={t(
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
)}
/> />
<Collapse expanded={form.values.hasApp}> <Collapse expanded={form.values.hasApp}>
@@ -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"; "use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core"; 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 { import {
IconAlertTriangle,
IconCategoryPlus, IconCategoryPlus,
IconDots, IconDots,
IconFileText, IconFileText,
@@ -20,10 +20,10 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { formatBytes, useTimeAgo } from "@homarr/common"; 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 { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals"; 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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -32,9 +32,41 @@ import { OverflowBadge } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number]; type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
type DockerEndpoint = RouterOutputs["docker"]["getContainers"]["endpoints"][number];
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) => const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.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>[] => [ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
{ {
@@ -157,32 +189,39 @@ interface DockerTableProps {
export function DockerTable({ initialData }: DockerTableProps) { export function DockerTable({ initialData }: DockerTableProps) {
const t = useI18n(); const t = useI18n();
const tDocker = useScopedI18n("docker"); 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, initialData,
refetchOnMount: false, 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 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 containers = data?.containers ?? [];
const unavailableEndpoints = data?.endpoints.filter(({ status }) => status === "unavailable") ?? [];
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data: containers, data: containers,
state: { isLoading: isFetching }, getRowId: (row) => row.resourceId,
state: { isLoading: isFetching || refreshInventory.isPending },
enableDensityToggle: false, enableDensityToggle: false,
enableColumnActions: false, enableColumnActions: false,
enableColumnFilters: false, enableColumnFilters: false,
@@ -207,13 +246,18 @@ export function DockerTable({ initialData }: DockerTableProps) {
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } }, initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
mantineTableBodyCellProps: { style: { padding: "4px 8px" } }, mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
mantineTableHeadCellProps: { 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: () => ( renderTopToolbarCustomActions: () => (
<Button <Button
variant="default" variant="default"
rightSection={<IconRefresh size="1rem" />} rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()} onClick={() => refreshInventory.mutate()}
loading={isFetching} loading={isFetching || refreshInventory.isPending}
> >
{tDocker("action.refresh.label")} {tDocker("action.refresh.label")}
</Button> </Button>
@@ -229,7 +273,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
totalCount: String(table.getRowCount()), totalCount: String(table.getRowCount()),
})} })}
</Text> </Text>
<ContainerActionBar selectedContainers={dockerContainers} /> <ContainerActionBar selectedContainers={dockerContainers} endpoints={data?.endpoints ?? []} />
</Group> </Group>
); );
}, },
@@ -238,10 +282,18 @@ export function DockerTable({ initialData }: DockerTableProps) {
}); });
return ( 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> <Text>{tDocker("table.updated", { when: relativeTime })}</Text>
<MantineReactTable table={table} /> <MantineReactTable table={table} />
</> </Stack>
); );
} }
@@ -252,22 +304,21 @@ const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon:
{ action: "remove", icon: IconTrash, color: "red" }, { 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 t = useScopedI18n("docker.action");
const tAll = useI18n();
const router = useRouter(); const router = useRouter();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { openModal } = useModalAction(AddDockerAppToHomarr); const { openModal } = useModalAction(AddDockerAppToHomarr);
const confirmRemoval = useDockerContainerRemovalConfirmation();
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") => const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
clientApi.docker[`${action}All`].useMutation({ clientApi.docker[`${action}All`].useMutation({
async onSettled() { async onSettled() {
await utils.docker.getContainers.invalidate(); await utils.docker.getContainers.invalidate();
}, },
onSuccess() { onSuccess(results) {
showSuccessNotification({ showContainerActionResult(action, [container], results, tAll);
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
});
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
@@ -294,6 +345,7 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
leftSection={<IconFileText size="1rem" />} leftSection={<IconFileText size="1rem" />}
disabled={!endpointHasCapability(endpoint, "logs")}
onClick={() => router.push(createContainerLogsPath(container))} onClick={() => router.push(createContainerLogsPath(container))}
> >
{t("logs.label")} {t("logs.label")}
@@ -304,7 +356,12 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
key={action} key={action}
leftSection={<Icon size="1rem" />} leftSection={<Icon size="1rem" />}
color={color} 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`)} {t(`${action}.label`)}
</Menu.Item> </Menu.Item>
@@ -324,18 +381,24 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
interface ContainerActionBarProps { interface ContainerActionBarProps {
selectedContainers: DockerContainer[]; selectedContainers: DockerContainer[];
endpoints: DockerEndpoint[];
} }
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => { const ContainerActionBar = ({ selectedContainers, endpoints }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action"); const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr); const { openModal } = useModalAction(AddDockerAppToHomarr);
const selectedIds = selectedContainers.map((container) => container.id);
return ( return (
<Group gap="xs"> <Group gap="xs">
{containerActions.map(({ action, icon, color }) => ( {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 <Button
leftSection={<IconCategoryPlus />} leftSection={<IconCategoryPlus />}
@@ -353,12 +416,22 @@ interface ContainerActionBarButtonProps {
icon: TablerIcon; icon: TablerIcon;
color: MantineColor; color: MantineColor;
action: "start" | "stop" | "restart" | "remove"; action: "start" | "stop" | "restart" | "remove";
selectedIds: string[]; selectedContainers: DockerContainer[];
endpoints: DockerEndpoint[];
} }
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => { const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
const t = useScopedI18n("docker.action"); const t = useScopedI18n("docker.action");
const tAll = useI18n();
const utils = clientApi.useUtils(); 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({ const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation({
async onSettled() { async onSettled() {
@@ -367,23 +440,28 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
}); });
const handleClickAsync = async () => { const handleClickAsync = async () => {
await mutateAsync( const runActionAsync = async () => {
{ ids: props.selectedIds }, await mutateAsync(
{ { targets: props.selectedContainers.map(getContainerTarget) },
onSuccess() { {
showSuccessNotification({ onSuccess(results) {
title: t(`${props.action}.notification.success.title`), showContainerActionResult(props.action, props.selectedContainers, results, tAll);
message: t(`${props.action}.notification.success.message`), },
}); 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 ( return (
@@ -392,6 +470,7 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
color={props.color} color={props.color}
onClick={handleClickAsync} onClick={handleClickAsync}
loading={isPending} loading={isPending}
disabled={!isAllowed}
variant="light" variant="light"
> >
{t(`${props.action}.label`)} {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 ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option"); const t = useScopedI18n("docker.field.state.option");
@@ -13,7 +13,7 @@ import { ClientSideDockerLogsTerminal } from "./client";
interface DockerContainerLogsPageProps { interface DockerContainerLogsPageProps {
params: Promise<{ containerId: string }>; params: Promise<{ containerId: string }>;
searchParams: Promise<{ name?: string }>; searchParams: Promise<{ endpointId?: string; name?: string }>;
} }
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) { export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
@@ -23,10 +23,12 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
} }
const { containerId } = await params; const { containerId } = await params;
const { name } = await searchParams; const { endpointId, name } = await searchParams;
const resolvedEndpointId = endpointId ?? (await getOnlyDockerEndpointIdAsync());
if (!resolvedEndpointId) notFound();
try { try {
await api.docker.logs({ id: containerId, tail: 1 }); await api.docker.logs({ endpointId: resolvedEndpointId, id: containerId, tail: 1 });
} catch { } catch {
notFound(); notFound();
} }
@@ -40,8 +42,17 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
/> />
</Group> </Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black"> <Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal containerId={containerId} /> <ClientSideDockerLogsTerminal endpointId={resolvedEndpointId} containerId={containerId} />
</Box> </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"; import classes from "./terminal.module.css";
interface DockerLogsTerminalProps { interface DockerLogsTerminalProps {
endpointId: string;
containerId: string; containerId: string;
} }
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => { export const DockerLogsTerminal = ({ endpointId, containerId }: DockerLogsTerminalProps) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null); const terminalRef = useRef<Terminal | null>(null);
const pendingLogsRef = useRef<string[]>([]); const pendingLogsRef = useRef<string[]>([]);
@@ -31,7 +32,7 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
}, []); }, []);
clientApi.docker.subscribeLogs.useSubscription( clientApi.docker.subscribeLogs.useSubscription(
{ id: containerId, tail: 200 }, { endpointId, id: containerId, tail: 200 },
{ {
onData: writeToTerminal, onData: writeToTerminal,
onError(err) { onError(err) {
@@ -1,12 +1,15 @@
import { headers } from "next/headers";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { env } from "@homarr/docker/env"; import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { DockerReconciliation } from "./docker-reconciliation";
import { DockerTable } from "./docker-table"; import { DockerTable } from "./docker-table";
export default async function DockerPage() { export default async function DockerPage() {
@@ -15,7 +18,10 @@ export default async function DockerPage() {
notFound(); 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"); const tDocker = await getScopedI18n("docker");
return ( return (
@@ -23,7 +29,8 @@ export default async function DockerPage() {
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tDocker("title")}</Title> <Title order={1}>{tDocker("title")}</Title>
<DockerTable initialData={{ containers, timestamp }} /> <DockerReconciliation defaultServerOrigin={extractBaseUrlFromHeaders(requestHeaders)} />
<DockerTable initialData={{ containers, endpoints, timestamp }} />
</Stack> </Stack>
</> </>
); );
@@ -1,31 +1,38 @@
"use client"; "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 { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import type { KubernetesLabelResourceType } from "@homarr/definitions"; 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 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 { 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 { 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 { ResourceTile } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile";
import { useSelectedKubernetesContextId } from "../kubernetes-context-selector";
export function ClusterDashboard() { export function ClusterDashboard() {
const t = useI18n(); const t = useI18n();
const tCluster = useScopedI18n("kubernetes.cluster");
const contextId = useSelectedKubernetesContextId();
const { const {
data: clusterData, data: clusterData,
isLoading: isClusterLoading, isLoading: isClusterLoading,
isError: isClusterError, isError: isClusterError,
} = clientApi.kubernetes.cluster.getCluster.useQuery(); } = clientApi.kubernetes.cluster.getCluster.useQuery({ contextId: contextId ?? "" }, { enabled: Boolean(contextId) });
const { const {
data: resourceCountsData, data: resourceCountsData,
isLoading: isResourceCountsLoading, isLoading: isResourceCountsLoading,
isError: isResourceCountsError, isError: isResourceCountsError,
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery(); } = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery(
{ contextId: contextId ?? "" },
{ enabled: Boolean(contextId) },
);
return ( return (
<Stack bg="var(--mantine-color-body)"> <Stack bg="var(--mantine-color-body)">
@@ -46,6 +53,12 @@ export function ClusterDashboard() {
<Title>{t("kubernetes.cluster.capacity.title")}</Title> <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 }}> <SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{isClusterError {isClusterError
? Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />) ? Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />)
@@ -63,9 +76,11 @@ export function ClusterDashboard() {
? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />) ? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />)
: isResourceCountsLoading : isResourceCountsLoading
? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />) ? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />)
: resourceCountsData?.map((clusterResourceCount) => ( : contextId &&
resourceCountsData?.map((clusterResourceCount) => (
<ResourceTile <ResourceTile
count={clusterResourceCount.count} count={clusterResourceCount.count}
contextId={contextId}
label={clusterResourceCount.label as KubernetesLabelResourceType} label={clusterResourceCount.label as KubernetesLabelResourceType}
key={clusterResourceCount.label} key={clusterResourceCount.label}
/> />
@@ -11,13 +11,18 @@ import classes from "./resource-tile.module.css";
interface ResourceTileProps { interface ResourceTileProps {
count: number; count: number;
contextId: string;
label: KubernetesLabelResourceType; label: KubernetesLabelResourceType;
} }
export function ResourceTile(props: ResourceTileProps) { export function ResourceTile(props: ResourceTileProps) {
const t = useI18n(); const t = useI18n();
return ( 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"> <Group justify="space-between" wrap="nowrap">
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} /> <Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
<Group gap="xs"> <Group gap="xs">
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface ConfigMapsTableComponentProps { interface ConfigMapsTableComponentProps {
contextId: string;
initialConfigMaps: RouterOutputs["kubernetes"]["configMaps"]["getConfigMaps"]; 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 tConfigMaps = useScopedI18n("kubernetes.configmaps");
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(undefined, { const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
initialData: initialData.initialConfigMaps, { contextId },
refetchOnMount: false, {
refetchOnWindowFocus: false, initialData: initialConfigMaps,
refetchOnReconnect: false, refetchOnMount: "always",
}); refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data, data,
@@ -8,14 +8,24 @@ import { getScopedI18n } from "@homarr/translation/server";
import { ConfigmapsTable } from "~/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table"; import { ConfigmapsTable } from "~/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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(); const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound(); 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"); const tConfigMaps = await getScopedI18n("kubernetes.configmaps");
return ( return (
@@ -23,7 +33,7 @@ export default async function ConfigMapsPage() {
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tConfigMaps("label")}</Title> <Title order={1}>{tConfigMaps("label")}</Title>
<ConfigmapsTable initialConfigMaps={configMaps} /> <ConfigmapsTable contextId={context.contextId} initialConfigMaps={configMaps} />
</Stack> </Stack>
</> </>
); );
@@ -16,9 +16,12 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface IngressesTableComponentProps { interface IngressesTableComponentProps {
contextId: string;
initialIngresses: RouterOutputs["kubernetes"]["ingresses"]["getIngresses"]; 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 tIngresses = useScopedI18n("kubernetes.ingresses");
const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(undefined, { const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
initialData: initialData.initialIngresses, { contextId },
refetchOnMount: false, {
refetchOnWindowFocus: false, initialData: initialIngresses,
refetchOnReconnect: false, refetchOnMount: "always",
}); refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data, data,
@@ -103,5 +110,9 @@ export function IngressesTable(initialData: IngressesTableComponentProps) {
columns: createColumns(tIngresses), columns: createColumns(tIngresses),
}); });
if (isError) {
return <KubernetesErrorPage />;
}
return <MantineReactTable table={table} />; 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 { IngressesTable } from "~/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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(); const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound(); 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"); const tIngresses = await getScopedI18n("kubernetes.ingresses");
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tIngresses("label")}</Title> <Title order={1}>{tIngresses("label")}</Title>
<IngressesTable initialIngresses={ingresses} /> <IngressesTable contextId={context.contextId} initialIngresses={ingresses} />
</Stack> </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); dayjs.extend(relativeTime);
interface NamespacesTableComponentProps { interface NamespacesTableComponentProps {
contextId: string;
initialNamespaces: RouterOutputs["kubernetes"]["namespaces"]["getNamespaces"]; 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 tNamespaces = useScopedI18n("kubernetes.namespaces");
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(undefined, { const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
initialData: initialData.initialNamespaces, { contextId },
refetchOnMount: false, {
refetchOnWindowFocus: false, initialData: initialNamespaces,
refetchOnReconnect: false, refetchOnMount: "always",
}); refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data, data,
@@ -8,21 +8,31 @@ import { getScopedI18n } from "@homarr/translation/server";
import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table"; import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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(); const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound(); 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"); const tNamespaces = await getScopedI18n("kubernetes.namespaces");
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tNamespaces("label")}</Title> <Title order={1}>{tNamespaces("label")}</Title>
<NamespacesTable initialNamespaces={namespaces} /> <NamespacesTable contextId={context.contextId} initialNamespaces={namespaces} />
</Stack> </Stack>
</> </>
); );
@@ -15,9 +15,12 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface NodesListComponentProps { interface NodesListComponentProps {
contextId: string;
initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"]; initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"];
} }
@@ -49,14 +52,14 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
accessorKey: "allocatableCpuPercentage", accessorKey: "allocatableCpuPercentage",
header: t("field.cpu.label"), header: t("field.cpu.label"),
Cell({ cell }) { Cell({ cell }) {
return getRingProgress(cell.row.original.allocatableCpuPercentage); return getRingProgress(cell.row.original.allocatableCpuPercentage, t("field.metricsUnavailable"));
}, },
}, },
{ {
accessorKey: "allocatableRamPercentage", accessorKey: "allocatableRamPercentage",
header: t("field.memory.label"), header: t("field.memory.label"),
Cell({ cell }) { 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 tNodes = useScopedI18n("kubernetes.nodes");
const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(undefined, { const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
initialData: initialData.initialNodes, { contextId },
refetchOnMount: false, {
refetchOnWindowFocus: false, initialData: initialNodes,
refetchOnReconnect: false, refetchOnMount: "always",
}); refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data, data,
@@ -112,10 +119,18 @@ export function NodesTable(initialData: NodesListComponentProps) {
columns: createColumns(tNodes), columns: createColumns(tNodes),
}); });
if (isError) {
return <KubernetesErrorPage />;
}
return <MantineReactTable table={table} />; 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 ( return (
<RingProgress <RingProgress
size={70} size={70}
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { NodesTable } from "~/app/[locale]/manage/tools/kubernetes/nodes/nodes-table"; import { NodesTable } from "~/app/[locale]/manage/tools/kubernetes/nodes/nodes-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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(); const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound(); 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"); const tNodes = await getScopedI18n("kubernetes.nodes");
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tNodes("label")}</Title> <Title order={1}>{tNodes("label")}</Title>
<NodesTable initialNodes={nodes} /> <NodesTable contextId={context.contextId} initialNodes={nodes} />
</Stack> </Stack>
</> </>
); );
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table"; import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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(); const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound(); 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"); const tPods = await getScopedI18n("kubernetes.pods");
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tPods("label")}</Title> <Title order={1}>{tPods("label")}</Title>
<PodsTable initialPods={pods} /> <PodsTable contextId={context.contextId} initialPods={pods} />
</Stack> </Stack>
</> </>
); );
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface PodsTableComponentProps { interface PodsTableComponentProps {
contextId: string;
initialPods: RouterOutputs["kubernetes"]["pods"]["getPods"]; 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 tPods = useScopedI18n("kubernetes.pods");
const { data } = clientApi.kubernetes.pods.getPods.useQuery(undefined, { const { data } = clientApi.kubernetes.pods.getPods.useQuery(
initialData: initialData.initialPods, { contextId },
refetchOnMount: false, {
refetchOnWindowFocus: false, initialData: initialPods,
refetchOnReconnect: false, refetchOnMount: "always",
}); refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data, data,
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table"; import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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(); const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound(); 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"); const tSecrets = await getScopedI18n("kubernetes.secrets");
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tSecrets("label")}</Title> <Title order={1}>{tSecrets("label")}</Title>
<SecretsTable initialSecrets={secrets} /> <SecretsTable contextId={context.contextId} initialSecrets={secrets} />
</Stack> </Stack>
</> </>
); );
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface SecretsTableComponentProps { interface SecretsTableComponentProps {
contextId: string;
initialSecrets: RouterOutputs["kubernetes"]["secrets"]["getSecrets"]; 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 tSecrets = useScopedI18n("kubernetes.secrets");
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(undefined, { const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
initialData: initialData.initialSecrets, { contextId },
refetchOnMount: false, {
refetchOnWindowFocus: false, initialData: initialSecrets,
refetchOnReconnect: false, refetchOnMount: "always",
}); refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data, data,
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table"; import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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(); const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound(); 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"); const tServices = await getScopedI18n("kubernetes.services");
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tServices("label")}</Title> <Title order={1}>{tServices("label")}</Title>
<ServicesTable initialServices={services} /> <ServicesTable contextId={context.contextId} initialServices={services} />
</Stack> </Stack>
</> </>
); );
@@ -17,6 +17,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface ServicesTableComponentProps { interface ServicesTableComponentProps {
contextId: string;
initialServices: RouterOutputs["kubernetes"]["services"]["getServices"]; 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 tServices = useScopedI18n("kubernetes.services");
const { data } = clientApi.kubernetes.services.getServices.useQuery(undefined, { const { data } = clientApi.kubernetes.services.getServices.useQuery(
initialData: initialData.initialServices, { contextId },
refetchOnMount: false, {
refetchOnWindowFocus: false, initialData: initialServices,
refetchOnReconnect: false, refetchOnMount: "always",
}); refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data, data,
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table"; import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; 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(); const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
notFound(); 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"); const tVolumes = await getScopedI18n("kubernetes.volumes");
return ( return (
<> <>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title order={1}>{tVolumes("label")}</Title> <Title order={1}>{tVolumes("label")}</Title>
<VolumesTable initialVolumes={volumes} /> <VolumesTable contextId={context.contextId} initialVolumes={volumes} />
</Stack> </Stack>
</> </>
); );
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface VolumesTableComponentProps { interface VolumesTableComponentProps {
contextId: string;
initialVolumes: RouterOutputs["kubernetes"]["volumes"]["getVolumes"]; 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 tVolumes = useScopedI18n("kubernetes.volumes");
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(undefined, { const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
initialData: initialData.initialVolumes, { contextId },
refetchOnMount: false, {
refetchOnWindowFocus: false, initialData: initialVolumes,
refetchOnReconnect: false, refetchOnMount: "always",
}); refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data, data,
+1 -1
View File
@@ -246,7 +246,7 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`). **"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`).
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs. **"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with \`targets\` containing the endpointId and id returned for each container.
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation. **"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
@@ -46,7 +46,7 @@ describe("getAssistantWidgetConfiguration", () => {
boardName: "Home", boardName: "Home",
kind: "audioStats", kind: "audioStats",
summary: "Show audio statistics", summary: "Show audio statistics",
integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"], integrationIds: ["navidrome-private", "navidrome-1", "audiobookshelf-1", "plex-1"],
}, },
settings, settings,
[ [
@@ -55,28 +55,28 @@ describe("getAssistantWidgetConfiguration", () => {
name: "Navidrome", name: "Navidrome",
kind: "navidrome", kind: "navidrome",
url: "https://music.example.com", url: "https://music.example.com",
permissions: { hasUseAccess: true }, permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
}, },
{ {
id: "audiobookshelf-1", id: "audiobookshelf-1",
name: "Audiobookshelf", name: "Audiobookshelf",
kind: "audiobookshelf", kind: "audiobookshelf",
url: "https://books.example.com", url: "https://books.example.com",
permissions: { hasUseAccess: true }, permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
}, },
{ {
id: "plex-1", id: "plex-1",
name: "Plex", name: "Plex",
kind: "plex", kind: "plex",
url: "https://plex.example.com", url: "https://plex.example.com",
permissions: { hasUseAccess: true }, permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
}, },
{ {
id: "navidrome-private", id: "navidrome-private",
name: "Private Navidrome", name: "Private Navidrome",
kind: "navidrome", kind: "navidrome",
url: "https://private.example.com", url: "https://private.example.com",
permissions: { hasUseAccess: false }, permissions: { hasUseAccess: false, hasInteractAccess: false, hasFullAccess: false },
}, },
], ],
); );
@@ -6,6 +6,7 @@ import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@ma
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react"; import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator"; import type { SettingsContextProps } from "@homarr/settings/creator";
@@ -20,9 +21,10 @@ import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress"; import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status"; import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts"; import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts";
import { IntegrationSelectModal } from "../integration/integration-select-modal";
interface AssistantIntegration extends IntegrationSelectOption { interface AssistantIntegration extends IntegrationSelectOption {
permissions: { hasUseAccess: boolean }; permissions: NonNullable<IntegrationSelectOption["permissions"]>;
} }
export const getAssistantWidgetConfiguration = ( export const getAssistantWidgetConfiguration = (
@@ -66,6 +68,7 @@ export const AssistantConfigureWidgetTool = ({
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => { }: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
const t = useScopedI18n("common.assistant.configureWidget"); const t = useScopedI18n("common.assistant.configureWidget");
const fullT = useI18n(); const fullT = useI18n();
const { data: session } = useSession();
const settings = useSettings(); const settings = useSettings();
const hasCompleteArguments = hasCompleteAssistantToolArguments(status); const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
const definition = args?.kind ? widgetImports[args.kind].definition : undefined; const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
@@ -80,10 +83,11 @@ export const AssistantConfigureWidgetTool = ({
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]); }, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
const missingRequiredIntegration = const missingRequiredIntegration =
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0; configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
const { openModal } = useModalAction(WidgetEditModal); const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const autoConfirming = useAssistantAutomaticAction({ const autoConfirming = useAssistantAutomaticAction({
toolCallId, toolCallId,
ready: result === undefined && configuration !== null, ready: result === undefined && configuration !== null && !missingRequiredIntegration,
completed: result !== undefined, completed: result !== undefined,
confirm: () => { confirm: () => {
if (!args || !configuration) return; if (!args || !configuration) return;
@@ -154,6 +158,26 @@ export const AssistantConfigureWidgetTool = ({
if (!configuration) return null; if (!configuration) return null;
const openConfigurationModal = (
value = configuration.value,
integrationData: IntegrationSelectOption[] = configuration.integrationData,
) =>
openWidgetModal(
{
kind: args.kind,
definition: widgetImports[args.kind].definition,
value,
integrationData,
integrationSupport: configuration.integrationSupport,
settings,
onSuccessfulEdit: ({ options, integrationIds }) =>
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
},
);
if (autoConfirming) { if (autoConfirming) {
return ( return (
<Box className={classes.appTool}> <Box className={classes.appTool}>
@@ -168,9 +192,32 @@ export const AssistantConfigureWidgetTool = ({
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm">{t("noIntegrationDescription")}</Text> <Text size="sm">{t("noIntegrationDescription")}</Text>
<Group gap="xs"> <Group gap="xs">
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light"> {session?.user.permissions.includes("integration-create") ? (
{t("manageIntegrations")} <Button
</Button> size="compact-sm"
variant="light"
onClick={() =>
openIntegrationModal({
allowedKinds:
definition && "supportedIntegrations" in definition
? (definition.supportedIntegrations as readonly IntegrationKind[])
: [],
onSuccess: (created) => {
if (!created) return;
openConfigurationModal({ ...configuration.value, integrationIds: [created.integration.id] }, [
created.integration,
]);
},
})
}
>
{t("connectIntegration")}
</Button>
) : (
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
{t("manageIntegrations")}
</Button>
)}
<Button <Button
size="compact-sm" size="compact-sm"
variant="default" variant="default"
@@ -205,28 +252,7 @@ export const AssistantConfigureWidgetTool = ({
</Text> </Text>
</Box> </Box>
</Group> </Group>
<Button <Button mt="md" fullWidth leftSection={<IconSettings size={17} />} onClick={() => openConfigurationModal()}>
mt="md"
fullWidth
leftSection={<IconSettings size={17} />}
onClick={() =>
openModal(
{
kind: args.kind,
definition: widgetImports[args.kind].definition,
value: configuration.value,
integrationData: configuration.integrationData,
integrationSupport: configuration.integrationSupport,
settings,
onSuccessfulEdit: ({ options, integrationIds }) =>
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
},
)
}
>
{t("review")} {t("review")}
</Button> </Button>
</Box> </Box>
@@ -0,0 +1,16 @@
.root {
position: absolute;
z-index: 3;
top: calc(var(--mantine-spacing-xl) * var(--board-canvas-inverse-scale, 1));
right: 0;
left: 0;
display: flex;
justify-content: center;
padding-inline: var(--mantine-spacing-md);
pointer-events: none;
}
.panel {
max-width: 42rem;
pointer-events: auto;
}
@@ -0,0 +1,54 @@
"use client";
import { Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { IconBox, IconLayoutDashboard, IconPlug, IconResize } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardAddActions } from "./use-board-add-actions";
import classes from "./board-empty-state.module.css";
export const BoardEmptyState = () => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions();
const t = useScopedI18n("board.emptyState");
const hasBoardContent = board.items.length > 0 || board.sections.some((section) => section.kind !== "empty");
if (!isEditMode || hasBoardContent) return null;
return (
<div className={classes.root}>
<Paper className={classes.panel} withBorder radius="md" p="lg">
<Stack align="center" gap="sm">
<ThemeIcon variant="light" size="xl" radius="md">
<IconLayoutDashboard size={24} />
</ThemeIcon>
<Stack align="center" gap={4}>
<Title order={3} size="h4" ta="center">
{t("title")}
</Title>
<Text c="dimmed" size="sm" ta="center" maw="60ch">
{t("description")}
</Text>
</Stack>
<Group justify="center">
<Button leftSection={<IconResize size={18} />} onClick={addWidget}>
{t("addWidget")}
</Button>
<Button variant="default" leftSection={<IconBox size={18} />} onClick={addApp}>
{t("addApp")}
</Button>
{canConnectService && (
<Button variant="default" leftSection={<IconPlug size={18} />} onClick={connectService}>
{t("connectService")}
</Button>
)}
</Group>
</Stack>
</Paper>
</div>
);
};
@@ -0,0 +1,17 @@
.root {
pointer-events: auto;
max-width: min(24rem, calc(100vw - 2rem));
}
.panel {
width: min(24rem, calc(100vw - 2rem));
}
.step {
border-radius: var(--mantine-radius-md);
padding: var(--mantine-spacing-xs);
}
.step[data-complete] {
background: light-dark(var(--mantine-color-green-0), rgba(47, 158, 68, 0.1));
}
@@ -0,0 +1,6 @@
declare const classes: {
readonly panel: string;
readonly root: string;
readonly step: string;
};
export default classes;
@@ -0,0 +1,131 @@
"use client";
import { ActionIcon, Affix, Button, Group, Paper, Progress, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconCheck, IconChevronDown, IconChecklist } from "@tabler/icons-react";
import { useIntegrationsWithUseAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { getBoardSetupProgress } from "./board-setup-progress";
import { useBoardAddActions } from "./use-board-add-actions";
import classes from "./board-setup-checklist.module.css";
export const BoardSetupChecklist = () => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const integrations = useIntegrationsWithUseAccess();
const { addWidget, addApp, connectService, canConnectService } = useBoardAddActions();
const [collapsed, setCollapsed] = useLocalStorage({
key: `homarr-board-setup-checklist-${board.id}`,
defaultValue: false,
});
const t = useScopedI18n("board.setupChecklist");
const trackSetup = useSetupAnalytics();
const progress = getBoardSetupProgress({
itemKinds: board.items.map((item) => item.kind),
usableIntegrationCount: integrations.length,
});
if (!isEditMode || progress.isComplete) return null;
if (collapsed) {
return (
<Affix position={{ bottom: 16, left: 16 }} className={classes.root}>
<Button
variant="default"
leftSection={<IconChecklist size={18} />}
onClick={() => {
trackSetup("checklist-resumed", { entryPoint: "board", outcome: "continued", hasBoardContext: true });
setCollapsed(false);
}}
>
{t("resume", { completed: progress.completedCount, total: progress.totalCount })}
</Button>
</Affix>
);
}
return (
<Affix position={{ bottom: 16, left: 16 }} className={classes.root}>
<Paper className={classes.panel} withBorder shadow="md" radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600}>{t("title")}</Text>
<Text c="dimmed" size="xs">
{t("description")}
</Text>
</div>
<Tooltip label={t("collapse")}>
<ActionIcon variant="subtle" aria-label={t("collapse")} onClick={() => setCollapsed(true)}>
<IconChevronDown size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Progress
value={progress.totalCount === 0 ? 0 : (progress.completedCount / progress.totalCount) * 100}
aria-label={t("progress")}
/>
<ChecklistStep
complete={progress.steps.content}
label={t("step.content.label")}
description={t("step.content.description")}
actionLabel={t("step.content.action")}
onAction={addWidget}
/>
<ChecklistStep
complete={progress.steps.app}
label={t("step.app.label")}
description={t("step.app.description")}
actionLabel={t("step.app.action")}
onAction={addApp}
/>
<ChecklistStep
complete={progress.steps.service}
label={t("step.service.label")}
description={canConnectService ? t("step.service.description") : t("step.service.permissionDescription")}
actionLabel={canConnectService ? t("step.service.action") : undefined}
onAction={canConnectService ? connectService : undefined}
/>
</Stack>
</Paper>
</Affix>
);
};
const ChecklistStep = ({
complete,
label,
description,
actionLabel,
onAction,
}: {
complete: boolean;
label: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}) => (
<Group className={classes.step} data-complete={complete || undefined} wrap="nowrap" align="flex-start">
<ThemeIcon variant={complete ? "filled" : "light"} color={complete ? "green" : "gray"} size="md" mt={2}>
{complete ? <IconCheck size={14} /> : <IconChecklist size={14} />}
</ThemeIcon>
<Stack gap={1} style={{ flex: 1 }}>
<Text size="sm" fw={600} td={complete ? "line-through" : undefined}>
{label}
</Text>
<Text size="xs" c="dimmed">
{description}
</Text>
</Stack>
{!complete && onAction && actionLabel && (
<Button variant="subtle" size="compact-xs" onClick={onAction}>
{actionLabel}
</Button>
)}
</Group>
);
@@ -0,0 +1,22 @@
import { describe, expect, test } from "vitest";
import { getBoardSetupProgress } from "./board-setup-progress";
describe("getBoardSetupProgress", () => {
test("derives resumable progress from the board and connected services", () => {
expect(getBoardSetupProgress({ itemKinds: ["clock", "app"], usableIntegrationCount: 1 })).toEqual({
steps: { content: true, app: true, service: true },
completedCount: 3,
totalCount: 3,
isComplete: true,
});
});
test("does not count an app as a data widget", () => {
expect(getBoardSetupProgress({ itemKinds: ["app"], usableIntegrationCount: 0 })).toMatchObject({
steps: { content: false, app: true, service: false },
completedCount: 1,
isComplete: false,
});
});
});
@@ -0,0 +1,22 @@
import type { WidgetKind } from "@homarr/definitions";
export interface BoardSetupProgressInput {
itemKinds: readonly WidgetKind[];
usableIntegrationCount: number;
}
export const getBoardSetupProgress = ({ itemKinds, usableIntegrationCount }: BoardSetupProgressInput) => {
const steps = {
content: itemKinds.some((kind) => kind !== "app"),
app: itemKinds.includes("app"),
service: usableIntegrationCount > 0,
};
const completedCount = Object.values(steps).filter(Boolean).length;
return {
steps,
completedCount,
totalCount: Object.keys(steps).length,
isComplete: completedCount === Object.keys(steps).length,
};
};
@@ -4,8 +4,9 @@ import type { Modify } from "@homarr/common/types";
import { getRootSectionLane } from "@homarr/definitions"; import { getRootSectionLane } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions"; import { widgetDefaultSizes } from "@homarr/definitions";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types"; import type { Board, ContainerSection, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout"; import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "./empty-position"; import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements"; import { getSectionElements } from "./section-elements";
@@ -15,33 +16,43 @@ export interface CreateItemInput {
kind: WidgetKind; kind: WidgetKind;
options?: Record<string, unknown>; options?: Record<string, unknown>;
integrationIds?: string[]; integrationIds?: string[];
targetSectionId?: string;
advancedOptions?: BoardItemAdvancedOptions;
} }
export const createItemCallback = export const createItemCallback =
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) => ({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) =>
(previous: Board): Board => { (previous: Board): Board => {
const firstSection = previous.sections const firstCanvasSection = previous.sections
.filter( .filter(
(section): section is EmptySection => (section): section is EmptySection =>
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main", section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
) )
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0); .at(0);
const requestedSection = targetSectionId
? previous.sections.find(
(section): section is EmptySection | ContainerSection =>
section.id === targetSectionId && (section.kind === "empty" || section.kind === "container"),
)
: undefined;
let targetSection = requestedSection ?? firstCanvasSection;
if (!firstSection) return previous; if (!targetSection) return previous;
const itemLayouts = createItemLayouts(previous, firstSection, kind); let itemLayouts = createItemLayouts(previous, targetSection, kind);
if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) {
targetSection = firstCanvasSection;
itemLayouts = createItemLayouts(previous, targetSection, kind);
}
if (itemLayouts.length === 0) return previous;
const widget = { const widget = {
id: id ?? createId(), id: id ?? createId(),
kind, kind,
options, options,
layouts: itemLayouts, layouts: itemLayouts,
integrationIds, integrationIds,
advancedOptions: { advancedOptions: advancedOptions ?? { title: null, customCssClasses: [], borderColor: "" },
title: null,
customCssClasses: [],
borderColor: "",
},
} satisfies Modify< } satisfies Modify<
Item, Item,
{ {
@@ -59,14 +70,25 @@ const defaultItemSize = { width: 1, height: 1 };
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize; const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => { const createItemLayouts = (
board: Board,
currentSection: EmptySection | ContainerSection,
kind: WidgetKind,
): ItemLayout[] => {
const layouts = getBoardLayouts(board); const layouts = getBoardLayouts(board);
const itemSize = getWidgetItemSize(kind); const itemSize = getWidgetItemSize(kind);
return layouts.map((layoutId) => { return layouts.flatMap((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId); const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width; const containerLayout =
currentSection.kind === "container"
? currentSection.layouts.find((layout) => layout.layoutId === layoutId)
: undefined;
const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main";
if (currentSection.kind === "container" && !containerLayout) return [];
const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0);
if (columnCount === 0) return [];
const size = { const size = {
width: Math.min(itemSize.width, columnCount), width: Math.min(itemSize.width, columnCount),
height: itemSize.height, height: itemSize.height,
@@ -80,12 +102,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
throw new Error("Your board is full"); throw new Error("Your board is full");
} }
return { return [
width: size.width, {
height: size.height, width: size.width,
...emptyPosition, height: size.height,
sectionId: currentSection.id, ...emptyPosition,
layoutId, sectionId: currentSection.id,
}; layoutId,
},
];
}); });
}; };
@@ -149,6 +149,99 @@ describe("item actions create-item", () => {
}); });
}); });
test("places a new item directly in the requested container", () => {
const board = new BoardMockBuilder()
.addEmptySection({ id: "canvas" })
.addSection(
new ContainerSectionMockBuilder({ id: "media" })
.addLayout({ layoutId: "layout", parentSectionId: "canvas", width: 4, height: 3 })
.build(),
)
.build();
const layout = board.layouts.at(0);
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
]);
});
test("omits layouts where the requested container has no layout entry", () => {
const board = new BoardMockBuilder()
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
.addEmptySection({ id: "canvas" })
.addSection(
new ContainerSectionMockBuilder({ id: "media" })
.addLayout({ layoutId: "base", parentSectionId: "canvas", width: 4, height: 3 })
.build(),
)
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
if (!baseLayout) throw new Error("Expected a base layout");
baseLayout.id = "base";
const result = createItemCallback({ id: "calendar", kind: "clock", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "base", sectionId: "media" }),
]);
});
test("sizes a new item for its requested rail and omits layouts where that rail is hidden", () => {
const board = new BoardMockBuilder()
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
.addEmptySection({ id: "canvas" })
.addEmptySection({ id: "left-rail", xOffset: -1 })
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
if (!baseLayout) throw new Error("Expected a base layout");
baseLayout.leftGutterColumnCount = 2;
const result = createItemCallback({ id: "missing", kind: "mediaMissing", targetSectionId: "left-rail" })(board);
expect(result.items.find((item) => item.id === "missing")?.layouts).toEqual([
expect.objectContaining({ layoutId: baseLayout.id, sectionId: "left-rail", width: 2 }),
]);
});
test("falls back to the main canvas instead of creating a ghost item for a disabled rail", () => {
const board = new BoardMockBuilder()
.addEmptySection({ id: "canvas" })
.addEmptySection({ id: "left-rail", xOffset: -1 })
.build();
const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "left-rail" })(board);
expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([
expect.objectContaining({ sectionId: "canvas" }),
]);
});
test("falls back to the main canvas when a requested destination no longer exists", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build();
const layout = board.layouts.at(0);
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
const result = createItemCallback({ id: "clock", kind: "clock", targetSectionId: "removed" })(board);
expect(result.items.find((item) => item.id === "clock")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "canvas" }),
]);
});
test("commits configured advanced options in the creation transaction", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas" }).build();
const advancedOptions = { title: "Kitchen", customCssClasses: ["compact"], borderColor: "#abc" };
const result = createItemCallback({ id: "clock", kind: "clock", advancedOptions })(board);
expect(result.items.find((item) => item.id === "clock")?.advancedOptions).toEqual(advancedOptions);
});
test("clamps wide defaults and places below a full automatic canvas", () => { test("clamps wide defaults and places below a full automatic canvas", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build(); const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
const layout = board.layouts[0]; const layout = board.layouts[0];
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { getBoardRecipeDismissalKey, getBoardRecipeRecommendations } from "./board-recipe-recommendations";
test("recipe dismissal is scoped to both the board and integration kind", () => {
expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-b", "piHole"));
expect(getBoardRecipeDismissalKey("board-a", "piHole")).not.toBe(getBoardRecipeDismissalKey("board-a", "jellyfin"));
});
describe("getBoardRecipeRecommendations", () => {
test("prioritizes widgets unlocked by the newly connected service", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole", "jellyfin"],
existingItemKinds: [],
preferredIntegrationKind: "jellyfin",
});
const firstFallbackIndex = recommendations.findIndex(({ isNewlyAvailable }) => !isNewlyAvailable);
expect(recommendations.some(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
if (firstFallbackIndex !== -1) {
expect(recommendations.slice(0, firstFallbackIndex).every(({ isNewlyAvailable }) => isNewlyAvailable)).toBe(true);
expect(recommendations.slice(firstFallbackIndex).every(({ isNewlyAvailable }) => !isNewlyAvailable)).toBe(true);
}
});
test("does not recommend widget kinds already present on the board", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole"],
existingItemKinds: ["dnsHoleSummary"],
preferredIntegrationKind: "piHole",
});
expect(recommendations.every(({ widgetKind }) => widgetKind !== "dnsHoleSummary")).toBe(true);
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
});
test("deduplicates configured kinds and emitted widget capabilities", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole", "piHole"],
existingItemKinds: [],
limit: 10,
});
const widgetKinds = recommendations.map(({ widgetKind }) => widgetKind);
expect(new Set(widgetKinds).size).toBe(widgetKinds.length);
expect(recommendations.every(({ integrationKind }) => integrationKind === "piHole")).toBe(true);
});
test("falls back to other configured integrations when the new service has no missing widgets", () => {
const recommendations = getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole", "jellyfin"],
existingItemKinds: ["dnsHoleSummary", "dnsHoleControls"],
preferredIntegrationKind: "piHole",
limit: 1,
});
expect(recommendations).toEqual([
expect.objectContaining({ integrationKind: "jellyfin", isNewlyAvailable: false }),
]);
});
test("returns no recipes without a usable connection or capacity", () => {
expect(
getBoardRecipeRecommendations({
configuredIntegrationKinds: [],
existingItemKinds: [],
}),
).toEqual([]);
expect(
getBoardRecipeRecommendations({
configuredIntegrationKinds: ["piHole"],
existingItemKinds: [],
limit: 0,
}),
).toEqual([]);
});
});
@@ -0,0 +1,60 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
export interface BoardRecipeRecommendation {
widgetKind: WidgetKind;
integrationKind: IntegrationKind;
isNewlyAvailable: boolean;
score: number;
}
export const getBoardRecipeDismissalKey = (boardId: string, integrationKind: IntegrationKind) =>
`homarr-board-recipes-${boardId}-${integrationKind}`;
interface BoardRecipeRecommendationInput {
configuredIntegrationKinds: readonly IntegrationKind[];
existingItemKinds: readonly WidgetKind[];
preferredIntegrationKind?: IntegrationKind;
limit?: number;
}
/**
* Suggests missing widgets that can use an existing connection. The newly
* connected service ranks first, followed by specific capabilities with fewer
* unrelated provider matches. Inputs and outputs are deduplicated so this can
* remain a small, explainable client-side recommendation layer.
*/
export const getBoardRecipeRecommendations = ({
configuredIntegrationKinds,
existingItemKinds,
preferredIntegrationKind,
limit = 3,
}: BoardRecipeRecommendationInput): BoardRecipeRecommendation[] => {
const availableKinds = new Set(configuredIntegrationKinds);
const existingKinds = new Set(existingItemKinds);
return widgetKinds
.filter((widgetKind) => !existingKinds.has(widgetKind))
.flatMap((widgetKind) => {
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
if (!capability) return [];
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
if (matches.length === 0) return [];
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
const integrationKind = preferredMatch ? preferredIntegrationKind : matches[0];
if (!integrationKind) return [];
return [
{
widgetKind,
integrationKind,
isNewlyAvailable: Boolean(preferredMatch),
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
},
];
})
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.slice(0, Math.max(0, limit));
};
@@ -40,7 +40,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions(); useItemActions();
const { integrations: integrationData = [], section } = useSectionContext(); const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings(); const settings = useSettings();
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
@@ -77,8 +77,10 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
}); });
refResetErrorBoundaryOnNextRender.current = true; refResetErrorBoundaryOnNextRender.current = true;
}, },
integrationData: integrationData.filter( onIntegrationSaved: resetErrorBoundary,
integrationData: (integrationData ?? []).filter(
(integration) => (integration) =>
integration.permissions.hasUseAccess &&
"supportedIntegrations" in definition && "supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
), ),
@@ -1,6 +1,44 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; import {
getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync,
tryLockSelection,
unlockSelection,
} from "./item-select-data";
describe("getWidgetConnectionStatus", () => {
const availableKinds = new Set(["sonarr" as const]);
it("reports widgets with a configured compatible integration as ready", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
});
it("reports required integrations that are not configured as needing setup", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("needsSetup");
});
it.each([
{ supportedIntegrations: [], connectionOptional: false },
{ supportedIntegrations: ["radarr" as const], connectionOptional: true },
])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
"noConnectionRequired",
);
});
});
describe("widget selection lock", () => { describe("widget selection lock", () => {
it("rejects repeated selection synchronously until the active selection finishes", () => { it("rejects repeated selection synchronously until the active selection finishes", () => {
@@ -45,4 +83,20 @@ describe("resolveMatchingIntegrationsAsync", () => {
).resolves.toEqual([]); ).resolves.toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled(); expect(ensureDataAsync).not.toHaveBeenCalled();
}); });
it("excludes matching integrations the user cannot use", async () => {
const currentData = [
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
];
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
}),
).resolves.toEqual([currentData[0]]);
});
}); });
@@ -1,5 +1,21 @@
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
export type WidgetConnectionStatus = "ready" | "needsSetup" | "noConnectionRequired";
export const getWidgetConnectionStatus = ({
supportedIntegrations,
availableKinds,
connectionOptional,
}: {
supportedIntegrations: readonly IntegrationKind[];
availableKinds: ReadonlySet<IntegrationKind>;
connectionOptional: boolean;
}): WidgetConnectionStatus => {
if (supportedIntegrations.some((kind) => availableKinds.has(kind))) return "ready";
if (supportedIntegrations.length === 0 || connectionOptional) return "noConnectionRequired";
return "needsSetup";
};
export const tryLockSelection = (lock: { current: boolean }) => { export const tryLockSelection = (lock: { current: boolean }) => {
if (lock.current) return false; if (lock.current) return false;
lock.current = true; lock.current = true;
@@ -10,7 +26,9 @@ export const unlockSelection = (lock: { current: boolean }) => {
lock.current = false; lock.current = false;
}; };
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({ export const resolveMatchingIntegrationsAsync = async <
TIntegration extends { kind: IntegrationKind; permissions?: { hasUseAccess: boolean } },
>({
hasIntegrationSupport, hasIntegrationSupport,
supportedIntegrations, supportedIntegrations,
currentData, currentData,
@@ -24,5 +42,8 @@ export const resolveMatchingIntegrationsAsync = async <TIntegration extends { ki
if (!hasIntegrationSupport) return []; if (!hasIntegrationSupport) return [];
const integrations = currentData ?? (await ensureDataAsync()); const integrations = currentData ?? (await ensureDataAsync());
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind)); return integrations.filter(
(integration) =>
integration.permissions?.hasUseAccess !== false && supportedIntegrations.includes(integration.kind),
);
}; };
@@ -1,26 +1,58 @@
import { useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; import {
Avatar,
Badge,
Box,
Button,
Center,
Divider,
Flex,
Group,
Image,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconApi, IconBuildingStore } from "@tabler/icons-react"; import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions"; import {
getIconUrl,
getBoardLaneColumnCount,
getIntegrationName,
getRootSectionLane,
widgetIntegrationSupport,
widgetKinds,
widgetKindsWithOptionalIntegrations,
} from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; import type { WidgetConnectionStatus } from "./item-select-data";
import {
getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync,
tryLockSelection,
unlockSelection,
} from "./item-select-data";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import classes from "./item-select-modal.module.css";
interface ItemSelectModalContentProps { interface ItemSelectModalContentProps {
actions: { closeModal: () => void }; actions: { closeModal: () => void };
@@ -28,6 +60,8 @@ interface ItemSelectModalContentProps {
customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined; customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined;
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>; ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
isAdmin: boolean; isAdmin: boolean;
canCreateIntegration: boolean;
initialSearch?: string;
} }
const ItemSelectModalContent = ({ const ItemSelectModalContent = ({
@@ -36,16 +70,63 @@ const ItemSelectModalContent = ({
customWidgetDefs, customWidgetDefs,
ensureIntegrationDataAsync, ensureIntegrationDataAsync,
isAdmin, isAdmin,
canCreateIntegration,
initialSearch = "",
}: ItemSelectModalContentProps) => { }: ItemSelectModalContentProps) => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState(initialSearch);
const t = useI18n();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const mainCanvasSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
const placementOptions = useMemo(
() => [
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
...board.sections.flatMap((section) => {
if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset);
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
return [
{
value: section.id,
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
},
];
}),
...board.sections
.filter((section) => section.kind === "container")
.map((section) => ({
value: section.id,
label: section.options.title.trim() || t("section.container.untitled"),
})),
],
[board.sections, currentLayout, mainCanvasSection, t],
);
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null); const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false); const selectionLock = useRef(false);
const t = useI18n(); const { createItem, removeItem } = useItemActions();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); useEffect(() => {
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
setTargetSectionId(placementOptions[0]?.value ?? null);
}, [placementOptions, targetSectionId]);
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const settings = useSettings();
const trackSetup = useSetupAnalytics();
const flowStartedAt = useRef<number | null>(null);
const availableKinds = useMemo(
() => new Set((integrationData ?? []).filter(({ permissions }) => permissions.hasUseAccess).map((i) => i.kind)),
[integrationData],
);
const items = useMemo( const items = useMemo(
() => () =>
@@ -93,6 +174,49 @@ const ItemSelectModalContent = ({
}); });
}; };
const notifyCreated = (updatedBoard: ReturnType<typeof createItem>, itemId: string, name: string) => {
const createdItem = updatedBoard?.items.find((item) => item.id === itemId);
if (!createdItem) {
showErrorNotification({
title: t("item.create.notification.error.title"),
message: t("item.create.notification.error.message"),
});
return false;
}
const actualSectionId =
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
createdItem.layouts[0]?.sectionId;
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
const notificationId = `board-item-created:${itemId}`;
showSuccessNotification({
id: notificationId,
title: t("item.create.notification.success.title"),
message: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">
{wasRelocated
? t("item.create.notification.success.relocatedMessage", { name, destination: actualDestination })
: t("item.create.notification.success.message", { name })}
</Text>
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
removeItem({ itemId });
notifications.hide(notificationId);
}}
>
{t("common.action.undo")}
</Button>
</Group>
),
autoClose: 10_000,
});
return true;
};
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => { const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
if (!tryLockSelection(selectionLock)) return; if (!tryLockSelection(selectionLock)) return;
setLoadingSelection(`custom:${customWidgetDefinition.id}`); setLoadingSelection(`custom:${customWidgetDefinition.id}`);
@@ -118,9 +242,15 @@ const ItemSelectModalContent = ({
integrationIds: [], integrationIds: [],
}, },
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => { onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
createItem({ id: itemId, kind: "customApi", integrationIds: [] }); const updatedBoard = createItem({
updateItemOptions({ itemId, newOptions: configuredOptions }); id: itemId,
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); kind: "customApi",
integrationIds: [],
options: configuredOptions,
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
}, },
integrationData: [], integrationData: [],
integrationSupport: false, integrationSupport: false,
@@ -140,6 +270,8 @@ const ItemSelectModalContent = ({
const handleAdd = async (kind: WidgetKind) => { const handleAdd = async (kind: WidgetKind) => {
if (!tryLockSelection(selectionLock)) return; if (!tryLockSelection(selectionLock)) return;
flowStartedAt.current = performance.now();
trackSetup("widget-started", { entryPoint: "board", intent: kind });
setLoadingSelection(kind); setLoadingSelection(kind);
preloadWidgetEditModal(); preloadWidgetEditModal();
try { try {
@@ -153,35 +285,89 @@ const ItemSelectModalContent = ({
ensureDataAsync: ensureIntegrationDataAsync, ensureDataAsync: ensureIntegrationDataAsync,
}); });
const integrationIds = matchingIntegrations.map((i) => i.id); const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
const itemId = createId(); const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const openEditor = (
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
) => {
const selectedIntegrationIds = availableIntegrations
.slice(0, maxIntegrations)
.map((integration) => integration.id);
openEditModal(
{
kind,
definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds: selectedIntegrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
const updatedBoard = createItem({
id: itemId,
kind,
options,
integrationIds: newIntegrationIds,
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
trackSetup("widget-completed", {
entryPoint: "board",
intent: kind,
outcome: "completed",
elapsedMs: flowStartedAt.current ? Math.round(performance.now() - flowStartedAt.current) : undefined,
});
},
integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
};
const integrationsRequired =
hasIntegrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
if (integrationsRequired && matchingIntegrationCount === 0) {
trackSetup("dependency-blocked", {
entryPoint: "board",
intent: kind,
outcome: "blocked",
canResolveInline: canCreateIntegration,
});
if (!canCreateIntegration) {
showErrorNotification({
title: t("item.create.missingIntegration.title"),
message: t("item.create.missingIntegration.noPermission"),
});
return;
}
actions.closeModal();
openIntegrationModal({
allowedKinds: (definition.supportedIntegrations ?? []).filter((integration) => integration !== "mock"),
onSuccess: (result) => {
if (result) {
trackSetup("dependency-resolved-inline", {
entryPoint: "board",
intent: kind,
outcome: "continued",
canResolveInline: true,
});
openEditor([result.integration]);
}
},
});
return;
}
createItem({ id: itemId, kind, integrationIds });
actions.closeModal(); actions.closeModal();
openEditor(matchingIntegrations);
openEditModal(
{
kind,
definition,
value: {
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
options: defaultOptions,
integrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
);
} catch (error) { } catch (error) {
notifyDefinitionLoadError(error); notifyDefinitionLoadError(error);
} finally { } finally {
@@ -191,114 +377,123 @@ const ItemSelectModalContent = ({
}; };
return ( return (
<SelectGridLayout <Flex gap="md" align="stretch">
search={search} <Box style={{ flex: "1 1 0%", minWidth: 0 }}>
onSearchChange={setSearch} <SelectGridLayout
placeholder={`${t("item.create.search")}...`} search={search}
ariaLabel={t("item.create.search")} onSearchChange={setSearch}
onSearchKeyDown={(event) => { placeholder={`${t("item.create.search")}...`}
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { ariaLabel={t("item.create.search")}
const [item] = filteredItems; onSearchKeyDown={(event) => {
if (item) void handleAdd(item.kind); if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
} const [item] = filteredItems;
}} if (item) void handleAdd(item.kind);
> }
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onIntent={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
}} }}
disabled={loadingSelection !== null} >
loading={loadingSelection === item.kind} {placementOptions.length > 1 && (
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))} <Box style={{ gridColumn: "1 / -1" }}>
/> <Select
))} label={t("item.create.destination.label")}
description={t("item.create.destination.description")}
{isAdmin && ( data={placementOptions}
<> value={targetSectionId}
<Divider onChange={setTargetSectionId}
label={t("customWidget.page.list.title")} allowDeselect={false}
labelPosition="center" />
my="sm" </Box>
style={{ gridColumn: "1 / -1" }} )}
/> {filteredItems.map((item) => (
{filteredCustomWidgets.map((def) => ( <WidgetItem
<Card key={item.kind}
key={def.id} item={item}
className={classes.card} onSelect={() => void handleAdd(item.kind)}
h={selectGridCardHeight} onFocus={() => {
withBorder void loadWidgetDefinition(item.kind).catch(() => undefined);
pos="relative" preloadWidgetEditModal();
style={{ overflow: "hidden" }} }}
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)} onPointerEnter={() => {
> void loadWidgetDefinition(item.kind).catch(() => undefined);
<Stack h="100%" gap="xs"> preloadWidgetEditModal();
<Group gap="sm" wrap="nowrap" align="flex-start"> }}
{def.iconUrl ? ( disabled={loadingSelection !== null}
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} /> loading={loadingSelection === item.kind}
) : ( connectionStatus={getWidgetConnectionStatus({
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} /> supportedIntegrations: item.supportedIntegrations,
)} availableKinds,
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}> connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
{def.name} })}
</Text> />
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
</Stack>
<Box
className={classes.action}
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button
onClick={() => void handleAddCustomWidget(def)}
variant="light"
size="xs"
fullWidth
disabled={loadingSelection !== null}
loading={loadingSelection === `custom:${def.id}`}
>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
))} ))}
<Box style={{ gridColumn: "1 / -1" }}> {isAdmin && (
{/* Opens in a new tab so unsaved board changes survive the detour. */} <>
<Button <Divider
component="a" label={t("customWidget.page.list.title")}
href="/manage/custom-widgets/workshop" labelPosition="center"
target="_blank" my="sm"
rel="noopener noreferrer" style={{ gridColumn: "1 / -1" }}
variant="default" />
fullWidth {filteredCustomWidgets.map((def) => (
leftSection={<IconBuildingStore size={16} />} <CatalogItem
> key={def.id}
{t("workshop.browseWorkshop")} height={selectGridCardHeight}
</Button> label={def.name}
</Box> status={t("item.create.connectionStatus.noConnectionRequired")}
</> disabled={loadingSelection !== null}
)} busy={loadingSelection === `custom:${def.id}`}
onSelect={() => void handleAddCustomWidget(def)}
onFocus={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
}}
onPointerEnter={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
}}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
<ConnectionStatusBadge status="noConnectionRequired" />
</Stack>
</CatalogItem>
))}
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && ( <Box style={{ gridColumn: "1 / -1" }}>
<Center p="xl"> {/* Opens in a new tab so unsaved board changes survive the detour. */}
<Text c="dimmed">{t("common.noResults")}</Text> <Button
</Center> component="a"
)} href="/manage/custom-widgets/workshop"
</SelectGridLayout> target="_blank"
rel="noopener noreferrer"
variant="default"
fullWidth
leftSection={<IconBuildingStore size={16} />}
>
{t("workshop.browseWorkshop")}
</Button>
</Box>
</>
)}
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
</Box>
</Flex>
); );
}; };
const ItemSelectModalFrame = ({ const ItemSelectModalFrame = ({
@@ -306,11 +501,13 @@ const ItemSelectModalFrame = ({
innerProps, innerProps,
}: { }: {
actions: { closeModal: () => void }; actions: { closeModal: () => void };
innerProps: { boardId: string }; innerProps: { boardId: string; initialIntegrationKind?: IntegrationKind; initialWidgetKind?: WidgetKind };
}) => { }) => {
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const { data: session } = useSession(); const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false; const isAdmin = session?.user.permissions.includes("admin") ?? false;
const canCreateIntegration = session?.user.permissions.includes("integration-create") ?? false;
const t = useI18n();
const { data: integrationData } = clientApi.integration.all.useQuery(); const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery( const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
{ boardId: innerProps.boardId }, { boardId: innerProps.boardId },
@@ -324,13 +521,23 @@ const ItemSelectModalFrame = ({
customWidgetDefs={customWidgetDefs} customWidgetDefs={customWidgetDefs}
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()} ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
isAdmin={isAdmin} isAdmin={isAdmin}
canCreateIntegration={canCreateIntegration}
initialSearch={
innerProps.initialWidgetKind
? t(`widget.${innerProps.initialWidgetKind}.name`)
: innerProps.initialIntegrationKind
? getIntegrationName(innerProps.initialIntegrationKind)
: undefined
}
/> />
); );
}; };
export const ItemSelectModal = createModal<{ boardId: string }>((props) => ( export const ItemSelectModal = createModal<{
<ItemSelectModalFrame {...props} /> boardId: string;
)).withOptions({ initialIntegrationKind?: IntegrationKind;
initialWidgetKind?: WidgetKind;
}>((props) => <ItemSelectModalFrame {...props} />).withOptions({
defaultTitle: (t) => t("item.create.title"), defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect, size: modalSizeSelect,
}); });
@@ -338,10 +545,11 @@ export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
const WidgetItem = ({ const WidgetItem = ({
item, item,
onSelect, onSelect,
onIntent, onFocus,
onPointerEnter,
disabled, disabled,
loading, loading,
hasMatchingIntegration, connectionStatus,
}: { }: {
item: { item: {
kind: WidgetKind; kind: WidgetKind;
@@ -351,27 +559,25 @@ const WidgetItem = ({
icon: TablerIcon; icon: TablerIcon;
}; };
onSelect: () => void; onSelect: () => void;
onIntent: () => void; onFocus: () => void;
onPointerEnter: () => void;
disabled: boolean; disabled: boolean;
loading: boolean; loading: boolean;
hasMatchingIntegration: boolean; connectionStatus: WidgetConnectionStatus;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
return ( return (
<Card <CatalogItem
className={classes.card} height={selectGridCardHeight}
h={selectGridCardHeight} label={item.name}
withBorder status={statusLabel}
pos="relative" onFocus={onFocus}
style={{ onPointerEnter={onPointerEnter}
overflow: "hidden", onSelect={onSelect}
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined, disabled={disabled}
borderWidth: hasMatchingIntegration ? 2 : undefined, busy={loading}
}}
onFocus={onIntent}
onPointerEnter={onIntent}
aria-busy={loading || undefined}
> >
<Stack h="100%" gap="xs"> <Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start"> <Group gap="sm" wrap="nowrap" align="flex-start">
@@ -385,24 +591,21 @@ const WidgetItem = ({
{item.description} {item.description}
</Text> </Text>
</Tooltip> </Tooltip>
<ConnectionStatusBadge status={connectionStatus} />
<SupportedIntegrations integrations={item.supportedIntegrations} /> <SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack> </Stack>
<Box </CatalogItem>
className={classes.action} );
pos="absolute" };
bottom={0}
left={0} const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
right={0} const t = useI18n();
p="xs" const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
style={{
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)", return (
}} <Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
> {t(`item.create.connectionStatus.${status}`)}
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}> </Badge>
{t(`item.create.addToBoard`)}
</Button>
</Box>
</Card>
); );
}; };
@@ -415,7 +618,7 @@ const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind
const moreCount = integrations.length - countToShow; const moreCount = integrations.length - countToShow;
return ( return (
<Group gap={2} mt="auto"> <Group gap={2}>
<Tooltip.Group closeDelay={100}> <Tooltip.Group closeDelay={100}>
<Group gap={2}> <Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => ( {integrations.slice(0, countToShow).map((integration) => (
@@ -3,13 +3,18 @@
import { Suspense, use } from "react"; import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core"; import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import type { FallbackProps } from "react-error-boundary";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals"; import { createModal, modalSizeForm } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals"; import type { WidgetEditModalProps } from "@homarr/widgets/modals";
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined; import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
export const loadWidgetEditModal = () => { export const loadWidgetEditModal = () => {
if (widgetEditModalPromise) return widgetEditModalPromise; if (widgetEditModalPromise) return widgetEditModalPromise;
@@ -24,6 +29,50 @@ export const loadWidgetEditModal = () => {
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined); export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined;
const loadIntegrationEditForm = () => {
if (integrationEditFormPromise) return integrationEditFormPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-form");
integrationEditFormPromise = currentPromise;
void currentPromise.catch(() => {
if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined;
});
return currentPromise;
};
const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => {
const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm());
return <EmbeddedIntegrationEditForm {...props} />;
};
const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
<Center py="xl">
<WidgetError
error={error}
resetErrorBoundary={() => {
integrationEditFormPromise = undefined;
resetErrorBoundary();
}}
/>
</Center>
);
const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
<ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}>
<Suspense
fallback={
<Center py="xl">
<Loader size="sm" />
</Center>
}
>
<LazyIntegrationEditFormContent {...props} />
</Suspense>
</ErrorBoundary>
);
interface LazyWidgetEditModalContentProps { interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void }; actions: { closeModal: () => void };
innerProps: WidgetEditModalProps<WidgetKind>; innerProps: WidgetEditModalProps<WidgetKind>;
@@ -32,7 +81,15 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal()); const { WidgetEditModal } = use(loadWidgetEditModal());
const Component = WidgetEditModal.component; const Component = WidgetEditModal.component;
return <Component {...props} />; return (
<Component
{...props}
innerProps={{
...props.innerProps,
integrationEditForm: LazyIntegrationEditForm,
}}
/>
);
}; };
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => ( export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
@@ -65,4 +122,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
return t("item.edit.title"); return t("item.edit.title");
}, },
size: modalSizeForm, size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
}); });
@@ -178,8 +178,11 @@ export const WidgetContextMenu = ({
), ),
})); }));
}, },
integrationData: integrationData.filter((integration) => onIntegrationSaved: handleRefetch,
definition.supportedIntegrations?.some((kind) => kind === integration.kind), integrationData: integrationData.filter(
(integration) =>
definition.supportedIntegrations?.some((kind) => kind === integration.kind) &&
integration.permissions.hasUseAccess,
), ),
integrationSupport: definition.supportedIntegrations !== undefined, integrationSupport: definition.supportedIntegrations !== undefined,
settings, settings,
@@ -192,6 +195,7 @@ export const WidgetContextMenu = ({
definition, definition,
integrationData, integrationData,
item, item,
handleRefetch,
openModal, openModal,
persistBoard, persistBoard,
settings, settings,
@@ -0,0 +1,48 @@
"use client";
import { useCallback } from "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 { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useItemActions } from "./items/item-actions";
import { ItemSelectModal } from "./items/item-select-modal";
import { useContainerActions } from "./sections/container/container-actions";
export const useBoardAddActions = () => {
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 canCreateApp = session?.user.permissions.includes("app-create") ?? false;
const canConnectService = session?.user.permissions.includes("integration-create") ?? false;
const addWidget = useCallback(() => openItemSelectModal({ boardId: board.id }), [board.id, openItemSelectModal]);
const addApp = useCallback(
() =>
openAppSelectModal({
onSelectMany: (apps) => {
apps.forEach((app) => createItem({ kind: "app", options: { appId: app.id } }));
},
withCreate: canCreateApp,
}),
[canCreateApp, createItem, openAppSelectModal],
);
const connectService = useCallback(
() => openIntegrationSelectModal({ completionBoardId: board.id }),
[board.id, openIntegrationSelectModal],
);
return {
addWidget,
addApp,
connectService,
addContainer,
canConnectService,
};
};
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import type { SetupMetricProperties } from "./setup-analytics";
describe("SetupMetricProperties", () => {
test("keeps the measurement contract coarse and configuration-free", () => {
const allowedKeys = [
"entryPoint",
"intent",
"outcome",
"elapsedMs",
"hasBoardContext",
"canResolveInline",
] satisfies (keyof SetupMetricProperties)[];
type ExpectedKey = (typeof allowedKeys)[number];
type ActualKey = keyof SetupMetricProperties;
const exactContract: [ActualKey] extends [ExpectedKey]
? [ExpectedKey] extends [ActualKey]
? true
: false
: false = true;
expect(exactContract).toBe(true);
expect(allowedKeys).not.toContain("url");
expect(allowedKeys).not.toContain("query");
expect(allowedKeys).not.toContain("recordId");
});
});
@@ -0,0 +1,51 @@
"use client";
import { useCallback } from "react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
export type SetupMetricEvent =
| "surface-opened"
| "intent-selected"
| "widget-started"
| "dependency-blocked"
| "dependency-resolved-inline"
| "widget-completed"
| "completion-recipe-selected"
| "checklist-resumed";
export type SetupMetricEntryPoint = "header" | "spotlight" | "board" | "assistant" | "docker" | "management";
export interface SetupMetricProperties {
entryPoint: SetupMetricEntryPoint;
intent?: string;
outcome?: "completed" | "blocked" | "continued";
elapsedMs?: number;
hasBoardContext?: boolean;
canResolveInline?: boolean;
}
/**
* Emits only coarse workflow measurements through Homarr's existing opt-in analytics gate.
* Never pass record IDs, names, URLs, search text, credentials, or provider responses here.
*/
export const useSetupAnalytics = () => {
const t = useI18n();
const { mutate } = clientApi.analytics.trackFeature.useMutation({
onError() {
showErrorNotification({
title: t("common.error"),
message: t("universalCreate.analyticsError"),
});
},
});
return useCallback(
(event: SetupMetricEvent, properties: SetupMetricProperties) => {
mutate({ feature: `setup:${event}`, properties: { ...properties } });
},
[mutate],
);
};
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import type { RankedUniversalCreateAction } from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
describe("getUniversalCreateActionDefinitions", () => {
test("only exposes actions allowed without a board context", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: false,
permissions: ["board-create", "integration-create"],
});
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
});
test("places globally-created apps in the library group", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
});
test("keeps board content actions available without global create permissions", () => {
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
});
test("exposes the complete surface to administrators on a board", () => {
const actions = getUniversalCreateActionDefinitions({
hasBoardContext: true,
permissions: ["admin", "app-create", "integration-create", "board-create"],
});
expect(actions.map(({ key }) => key)).toEqual([
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
]);
});
});
describe("filterAndRankUniversalCreateActions", () => {
const actions = [
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
{
key: "integration",
group: "library",
priority: 80,
name: "Connect service",
description: "Create an integration",
keywords: ["provider"],
},
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
] satisfies RankedUniversalCreateAction[];
test("uses product priority when no search is entered", () => {
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
"widget",
"integration",
"board",
]);
});
test("ranks a name prefix ahead of a description or keyword match", () => {
const results = filterAndRankUniversalCreateActions(actions, "create");
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
});
test("matches keywords and removes unrelated actions", () => {
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
});
});
@@ -0,0 +1,120 @@
import type { GroupPermissionKey } from "@homarr/definitions";
export const universalCreateActionKeys = [
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
] as const;
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
export interface UniversalCreateContext {
hasBoardContext: boolean;
permissions: readonly GroupPermissionKey[];
}
interface UniversalCreateActionDefinition {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
isVisible: (context: UniversalCreateContext) => boolean;
}
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
context.permissions.includes(permission);
const definitions: readonly UniversalCreateActionDefinition[] = [
{
key: "widget",
group: "currentBoard",
priority: 100,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "app",
group: "currentBoard",
priority: 90,
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
},
{
key: "integration",
group: "library",
priority: 80,
isVisible: (context) => hasPermission(context, "integration-create"),
},
{
key: "container",
group: "currentBoard",
priority: 70,
isVisible: ({ hasBoardContext }) => hasBoardContext,
},
{
key: "board",
group: "boards",
priority: 60,
isVisible: (context) => hasPermission(context, "board-create"),
},
{
key: "workshop",
group: "library",
priority: 40,
isVisible: (context) => hasPermission(context, "admin"),
},
{
key: "customWidget",
group: "library",
priority: 30,
isVisible: (context) => hasPermission(context, "admin"),
},
];
export interface RankedUniversalCreateAction {
key: UniversalCreateActionKey;
group: UniversalCreateActionGroup;
priority: number;
name: string;
description: string;
keywords?: readonly string[];
}
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
definitions
.filter((definition) => definition.isVisible(context))
.map((definition) =>
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
);
const normalize = (value: string) => value.trim().toLocaleLowerCase();
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
actions: readonly TAction[],
query: string,
) => {
const normalizedQuery = normalize(query);
return actions
.map((action) => {
const name = normalize(action.name);
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
const relevance =
normalizedQuery.length === 0
? 0
: name === normalizedQuery
? 300
: name.startsWith(normalizedQuery)
? 200
: searchableText.includes(normalizedQuery)
? 100
: -1;
return { action, relevance };
})
.filter(({ relevance }) => relevance >= 0)
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
.map(({ action }) => action);
};
@@ -0,0 +1,21 @@
.action {
width: 100%;
border-radius: var(--mantine-radius-md);
padding: var(--mantine-spacing-sm);
text-align: start;
}
@media (hover: hover) {
.action:hover {
background: var(--mantine-color-default-hover);
}
}
.action:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.actionIcon {
flex: 0 0 auto;
}
@@ -0,0 +1,5 @@
declare const classes: {
readonly action: string;
readonly actionIcon: string;
};
export default classes;
@@ -0,0 +1,178 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Group, Stack, Text, TextInput, ThemeIcon, UnstyledButton } from "@mantine/core";
import {
IconApps,
IconBox,
IconCode,
IconLayoutDashboard,
IconLayoutGridAdd,
IconPlugConnected,
IconSearch,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { AddBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import classes from "./universal-create-modal.module.css";
import type {
RankedUniversalCreateAction,
UniversalCreateActionGroup,
UniversalCreateActionKey,
} from "./universal-create-actions";
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
import type { SetupMetricEntryPoint } from "./setup-analytics";
import { useSetupAnalytics } from "./setup-analytics";
type BoardActionKey = "widget" | "app" | "integration" | "container";
export interface UniversalCreateModalProps {
boardActions?: Partial<Record<BoardActionKey, () => void>>;
entryPoint?: SetupMetricEntryPoint;
}
const actionIcons = {
widget: IconApps,
app: IconBox,
integration: IconPlugConnected,
container: IconLayoutGridAdd,
board: IconLayoutDashboard,
workshop: IconSearch,
customWidget: IconCode,
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ actions, innerProps }) => {
const { data: session } = useSession();
const router = useRouter();
const t = useScopedI18n("universalCreate");
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
const [query, setQuery] = useState("");
const trackedOpen = useRef(false);
const boardActions = innerProps.boardActions;
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
const trackSetup = useSetupAnalytics();
const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
const definitions = getUniversalCreateActionDefinitions({
hasBoardContext: boardActions !== undefined,
permissions,
});
useEffect(() => {
if (trackedOpen.current) return;
trackedOpen.current = true;
trackSetup("surface-opened", {
entryPoint,
hasBoardContext: boardActions !== undefined,
});
}, [boardActions, entryPoint, trackSetup]);
const invokeAction = (key: UniversalCreateActionKey) => {
const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
actions.closeModal();
trackSetup("intent-selected", {
entryPoint,
intent: key,
outcome: "continued",
hasBoardContext: boardActions !== undefined,
});
if (boardAction) {
boardAction();
return;
}
switch (key) {
case "app":
router.push("/manage/apps/new");
break;
case "integration":
router.push("/manage/integrations/new");
break;
case "board":
openAddBoardModal(undefined);
break;
case "workshop":
router.push("/manage/workshop");
break;
case "customWidget":
router.push("/manage/custom-widgets/new");
break;
case "widget":
case "container":
break;
}
};
const resolvedActions = definitions.map(
(definition) =>
({
...definition,
name: t(`action.${definition.key}.label`),
description: t(`action.${definition.key}.description`),
keywords: t(`action.${definition.key}.keywords`).split(" "),
}) satisfies RankedUniversalCreateAction,
);
const visibleActions = filterAndRankUniversalCreateActions(resolvedActions, query);
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
return (
<Stack gap="md">
<TextInput
data-autofocus
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={t("search.placeholder")}
aria-label={t("search.label")}
/>
{visibleActions.length === 0 ? (
<Text c="dimmed" ta="center" py="lg">
{t("search.noResults")}
</Text>
) : (
groups.map((group) => {
const groupActions = visibleActions.filter((action) => action.group === group);
if (groupActions.length === 0) return null;
return (
<Stack key={group} gap={4}>
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
{t(`group.${group}`)}
</Text>
{groupActions.map((action) => {
const Icon = actionIcons[action.key];
return (
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
<Group wrap="nowrap" align="flex-start">
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
<Icon size={18} stroke={1.5} />
</ThemeIcon>
<Stack gap={1}>
<Text fw={600} size="sm">
{action.name}
</Text>
<Text c="dimmed" size="xs">
{action.description}
</Text>
</Stack>
</Group>
</UnstyledButton>
);
})}
</Stack>
);
})
)}
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("universalCreate.title"),
size: "lg",
});
@@ -0,0 +1,35 @@
"use client";
import { useMemo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { UniversalCreateModal } from "./universal-create-modal";
/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
export const UniversalCreateSpotlightResult = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
const item = useMemo(
() => ({
id: "universal-create",
name: t("title"),
description: t("trigger.description"),
icon: IconPlus,
alwaysVisible: true,
interaction: () => ({
type: "javaScript" as const,
onSelect: () => {
openModal({ entryPoint: "spotlight" });
},
}),
}),
[openModal, t],
);
useRegisterSpotlightContextResults("universal-create", [item], [item]);
return null;
};
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { HeaderButton } from "~/components/layout/header/button";
import { UniversalCreateModal } from "./universal-create-modal";
export const UniversalCreateHeaderAction = () => {
const t = useScopedI18n("universalCreate");
const { openModal } = useModalAction(UniversalCreateModal);
return (
<HeaderButton onClick={() => openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
<IconPlus stroke={1.5} />
</HeaderButton>
);
};
export const UniversalCreateQueryGate = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { openModal } = useModalAction(UniversalCreateModal);
const handled = useRef(false);
const shouldOpen = searchParams.get("create") === "true";
useEffect(() => {
if (!shouldOpen) {
handled.current = false;
return;
}
if (handled.current) return;
handled.current = true;
openModal({ entryPoint: "spotlight" });
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("create");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [openModal, pathname, router, searchParams, shouldOpen]);
return null;
};
@@ -0,0 +1,140 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import {
createCustomWidgetSourceSetupValues,
CustomWidgetSourceSetupPanel,
isCustomWidgetSourceSetupReady,
} from "@homarr/custom-widgets/workbench";
import type {
CustomWidgetSourceSetupMessages,
CustomWidgetSourceSetupValue,
} from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface CustomWidgetSourceSetupDialogProps {
definitionId: string;
opened: boolean;
onClose(): void;
}
export function CustomWidgetSourceSetupDialog({
definitionId,
opened,
onClose,
}: CustomWidgetSourceSetupDialogProps) {
const t = useScopedI18n("customWidget");
const router = useRouter();
const utils = clientApi.useUtils();
const definitionQuery = clientApi.customWidget.get.useQuery({ id: definitionId }, { enabled: opened });
const configureMutation = clientApi.customWidget.sourceConfigure.useMutation();
const [saving, setSaving] = useState(false);
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
const setups = useMemo(
() => getCustomWidgetSourceSetups(definitionQuery.data?.sources ?? {}, definitionQuery.data?.secrets ?? []),
[definitionQuery.data],
);
useEffect(() => {
if (opened && definitionQuery.data) setValues(createCustomWidgetSourceSetupValues(setups));
}, [definitionQuery.data, opened, setups]);
const messages: CustomWidgetSourceSetupMessages = {
title: t("importReview.sourceSetup.title"),
description: t("sourceSetupDialog.description"),
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
baseUrl: t("workbench.sources.baseUrl"),
networkScope: t("workbench.sources.networkScope"),
authentication: t("workbench.sources.authentication"),
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
ready: t("importReview.sourceSetup.ready"),
needsUrl: t("importReview.sourceSetup.needsUrl"),
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
credentialsOptional: t("sourceSetupDialog.credentialsRequired"),
configured: t("workbench.sources.configured"),
secret: (kind) => t(`importReview.credentials.field.${kind}`),
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
};
const save = async () => {
if (!isCustomWidgetSourceSetupReady(setups, values) || saving) return;
setSaving(true);
try {
for (const setup of setups) {
const value = values[setup.sourceId];
if (!value) continue;
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
);
await configureMutation.mutateAsync({
definitionId,
sourceId: setup.sourceId,
baseUrl: value.baseUrl,
networkScope: value.networkScope,
secrets,
});
}
showSuccessNotification({
title: t("action.configureSources"),
message: t("notification.sourcesConfigured"),
});
await utils.customWidget.get.invalidate({ id: definitionId });
await utils.customWidget.list.invalidate();
await utils.customWidget.available.invalidate();
await utils.widget.customApi.getData.invalidate();
await revalidatePathActionAsync("/manage/custom-widgets");
router.refresh();
onClose();
} catch (error) {
showErrorNotification({
title: t("action.configureSources"),
message: error instanceof Error ? error.message : t("notification.sourceConfigurationError"),
});
} finally {
setSaving(false);
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("sourceSetupDialog.title")} size="lg">
{definitionQuery.isPending ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : definitionQuery.error ? (
<Text c="red" size="sm">
{definitionQuery.error.message}
</Text>
) : (
<Stack gap="md">
<CustomWidgetSourceSetupPanel
setups={setups}
values={values}
messages={messages}
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={saving}>
{t("sourceSetupDialog.cancel")}
</Button>
<Button
onClick={() => void save()}
loading={saving}
disabled={!isCustomWidgetSourceSetupReady(setups, values)}
>
{t("sourceSetupDialog.save")}
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
@@ -0,0 +1,90 @@
"use client";
import { useImperativeHandle, useRef } from "react";
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
import type { EditIntegrationFormHandle } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
export type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
export const EmbeddedIntegrationEditForm = ({
integrationId,
handleRef,
onSuccess,
}: EmbeddedIntegrationEditFormProps) => {
const t = useI18n();
const integrationFormRef = useRef<EditIntegrationFormHandle>(null);
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: integrationId,
});
useImperativeHandle(
handleRef,
() => ({
submitIfDirty: async () => {
if (!integrationFormRef.current?.isDirty()) {
return true;
}
return integrationFormRef.current.submit();
},
}),
[],
);
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
</Stack>
);
};
@@ -0,0 +1,194 @@
"use client";
import { ActionIcon, Button, Divider, Group, List, Stack, Text, ThemeIcon, Title, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconAppWindow, IconCheck, IconLayoutDashboard, IconPlugConnected, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, getWidgetKindsForIntegration } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import {
getBoardRecipeDismissalKey,
getBoardRecipeRecommendations,
} from "~/components/board/items/board-recipe-recommendations";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
interface IntegrationCompletionModalProps {
result: CreatedIntegrationResult;
boardId?: string;
}
export const IntegrationCompletionModal = createModal<IntegrationCompletionModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const board = useOptionalBoard();
const { data: session } = useSession();
const { data: integrations = [] } = clientApi.integration.all.useQuery();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const trackSetup = useSetupAnalytics();
const compatibleWidgets = getWidgetKindsForIntegration(innerProps.result.integration.kind);
const boardMatchesContext = board !== null && board.id === innerProps.boardId;
const canModifyBoard = boardMatchesContext && constructBoardPermissions(board, session).hasChangeAccess;
const dismissalKey = getBoardRecipeDismissalKey(
innerProps.boardId ?? "no-board-context",
innerProps.result.integration.kind,
);
const [recommendationsDismissed, setRecommendationsDismissed] = useLocalStorage({
key: dismissalKey,
defaultValue: false,
});
const configuredIntegrationKinds = [
...new Set<IntegrationKind>([
...integrations.map((integration) => integration.kind),
innerProps.result.integration.kind,
]),
];
const recommendations = boardMatchesContext
? getBoardRecipeRecommendations({
configuredIntegrationKinds,
existingItemKinds: board.items.map((item) => item.kind),
preferredIntegrationKind: innerProps.result.integration.kind,
})
: [];
const showRecommendations = canModifyBoard && !recommendationsDismissed && recommendations.length > 0;
const addWidget = () => {
if (!innerProps.boardId) return;
actions.closeModal();
openItemSelectModal({
boardId: innerProps.boardId,
initialIntegrationKind: innerProps.result.integration.kind,
});
};
const addRecommendedWidget = (widgetKind: (typeof recommendations)[number]["widgetKind"]) => {
if (!innerProps.boardId) return;
trackSetup("completion-recipe-selected", {
entryPoint: "board",
intent: "add-compatible-widget",
outcome: "continued",
hasBoardContext: true,
});
actions.closeModal();
openItemSelectModal({ boardId: innerProps.boardId, initialWidgetKind: widgetKind });
};
return (
<Stack gap="lg">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon color="green" variant="light" size="xl" radius="xl">
<IconCheck size={22} />
</ThemeIcon>
<Stack gap={2}>
<Title order={3} size="h4">
{t("integration.completion.title", { name: innerProps.result.integration.name })}
</Title>
<Text c="dimmed" size="sm">
{t("integration.completion.description")}
</Text>
</Stack>
</Group>
<List
spacing="sm"
icon={
<ThemeIcon color="green" size="sm" radius="xl">
<IconCheck size={12} />
</ThemeIcon>
}
>
<List.Item>
<Group gap="xs">
<IconPlugConnected size={17} />
<Text size="sm">{t("integration.completion.connectionReady")}</Text>
</Group>
</List.Item>
{innerProps.result.appId && (
<List.Item>
<Group gap="xs">
<IconAppWindow size={17} />
<Text size="sm">{t("integration.completion.appReady")}</Text>
</Group>
</List.Item>
)}
<List.Item>
<Group gap="xs">
<IconLayoutDashboard size={17} />
<Text size="sm">
{t("integration.completion.compatibleWidgets", { count: String(compatibleWidgets.length) })}
</Text>
</Group>
</List.Item>
</List>
{showRecommendations && (
<Stack gap="xs">
<Divider />
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600} size="sm">
{t("integration.completion.recipes.title")}
</Text>
<Text c="dimmed" size="xs">
{t("integration.completion.recipes.description")}
</Text>
</div>
<Tooltip label={t("integration.completion.recipes.dismiss")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("integration.completion.recipes.dismiss")}
onClick={() => setRecommendationsDismissed(true)}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Group>
{recommendations.map((recommendation) => (
<Group key={recommendation.widgetKind} justify="space-between" wrap="nowrap" gap="sm">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{t(`widget.${recommendation.widgetKind}.name`)}
</Text>
<Text c="dimmed" size="xs" truncate>
{t(
recommendation.isNewlyAvailable
? "integration.completion.recipes.newConnectionReason"
: "integration.completion.recipes.existingConnectionReason",
{
integration: recommendation.isNewlyAvailable
? innerProps.result.integration.name
: getIntegrationName(recommendation.integrationKind),
},
)}
</Text>
</Stack>
<Button variant="light" size="compact-xs" onClick={() => addRecommendedWidget(recommendation.widgetKind)}>
{t("integration.completion.recipes.add")}
</Button>
</Group>
))}
</Stack>
)}
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.close")}
</Button>
{innerProps.boardId && compatibleWidgets.length > 0 && (
<Button onClick={addWidget}>{t("integration.completion.chooseWidget")}</Button>
)}
</Group>
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("integration.completion.modalTitle"),
size: modalSizeForm,
});
@@ -1,25 +1,35 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Group, ScrollArea, Title } from "@mantine/core"; import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
import { IconArrowLeft } from "@tabler/icons-react"; import { IconArrowLeft, IconX } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions"; import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeSelect } from "@homarr/modals"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { IntegrationSelectGrid } from "./integration-select-grid"; import { IntegrationSelectGrid } from "./integration-select-grid";
import { IntegrationCompletionModal } from "./integration-completion-modal";
interface IntegrationSelectModalProps { interface IntegrationSelectModalProps {
onSuccess?: () => void; onSuccess?: (result?: CreatedIntegrationResult) => void;
enableMockIntegration?: boolean; enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
completionBoardId?: string;
initialKind?: IntegrationKind;
initialUrl?: string;
initialName?: string;
} }
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => { export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const [step, setStep] = useState<"select" | "form">("select"); const t = useI18n();
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null); const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
const handleSelect = (kind: IntegrationKind) => { const handleSelect = (kind: IntegrationKind) => {
setSelectedKind(kind); setSelectedKind(kind);
@@ -30,26 +40,52 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
setStep("select"); setStep("select");
setSelectedKind(null); setSelectedKind(null);
}; };
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
const handleSuccess = () => { const handleSuccess = (result?: CreatedIntegrationResult) => {
innerProps.onSuccess?.();
actions.closeModal(); actions.closeModal();
if (innerProps.onSuccess) {
innerProps.onSuccess(result);
} else if (result) {
openCompletionModal({ result, boardId: innerProps.completionBoardId });
}
}; };
if (step === "form" && selectedKind) { if (step === "form" && selectedKind) {
let HeaderIcon = IconArrowLeft;
let headerLabel = t("common.action.previous");
if (innerProps.initialKind) {
HeaderIcon = IconX;
headerLabel = t("common.action.close");
}
return ( return (
<ScrollArea.Autosize mah="80vh"> <ScrollArea.Autosize mah="80vh">
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={handleBack}> <UnstyledButton mb="md" onClick={handleFormBack} aria-label={headerLabel}>
<IconArrowLeft size={18} /> <Group gap="xs">
<IntegrationAvatar kind={selectedKind} size="sm" /> <HeaderIcon size={18} />
<Title order={4}>{getIntegrationName(selectedKind)}</Title> <IntegrationAvatar kind={selectedKind} size="sm" />
</Group> <Title order={4}>{getIntegrationName(selectedKind)}</Title>
<NewIntegrationForm kind={selectedKind} onSuccess={handleSuccess} onCancel={handleBack} /> </Group>
</UnstyledButton>
<NewIntegrationForm
kind={selectedKind}
initialUrl={innerProps.initialUrl}
initialName={innerProps.initialName}
onSuccess={handleSuccess}
onCancel={handleFormBack}
/>
</ScrollArea.Autosize> </ScrollArea.Autosize>
); );
} }
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} />; return (
<IntegrationSelectGrid
onSelect={handleSelect}
enableMockIntegration={innerProps.enableMockIntegration}
allowedKinds={innerProps.allowedKinds}
/>
);
}).withOptions({ }).withOptions({
defaultTitle: (t) => t("integration.action.create"), defaultTitle: (t) => t("integration.action.create"),
size: modalSizeSelect, size: modalSizeSelect,
@@ -3,6 +3,8 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
import { ClientBurger } from "./header/burger"; import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search"; import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target"; import { TourTarget } from "./header/tour-target";
@@ -36,6 +38,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
<DesktopSearchInput /> <DesktopSearchInput />
</TourTarget> </TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap"> <Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <UniversalCreateHeaderAction />}
{actions} {actions}
<MobileSearchButton /> <MobileSearchButton />
<TourTarget id="board-user-menu"> <TourTarget id="board-user-menu">
@@ -43,6 +46,8 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
</TourTarget> </TourTarget>
</Group> </Group>
</Group> </Group>
<UniversalCreateQueryGate />
<UniversalCreateSpotlightResult />
<LazySpotlight /> <LazySpotlight />
</AppShellHeader> </AppShellHeader>
); );
@@ -37,6 +37,7 @@ export interface ManageCollectionItemProps {
metadata?: ReactNode; metadata?: ReactNode;
badges?: ReactNode; badges?: ReactNode;
actions?: ReactNode; actions?: ReactNode;
actionsAlignment?: "flex-start" | "center";
} }
export const ManageCollectionItem = ({ export const ManageCollectionItem = ({
@@ -46,6 +47,7 @@ export const ManageCollectionItem = ({
metadata, metadata,
badges, badges,
actions, actions,
actionsAlignment = "flex-start",
}: ManageCollectionItemProps) => ( }: ManageCollectionItemProps) => (
<Paper component="li" p="sm" withBorder radius="md"> <Paper component="li" p="sm" withBorder radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm"> <Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
@@ -59,7 +61,7 @@ export const ManageCollectionItem = ({
{metadata} {metadata}
</Stack> </Stack>
{actions && ( {actions && (
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}> <Group gap={4} wrap="nowrap" style={{ flexShrink: 0, alignSelf: actionsAlignment }}>
{actions} {actions}
</Group> </Group>
)} )}
+3 -3
View File
@@ -78,7 +78,7 @@ describe("lazy widget application graph", () => {
await page.goto(`${baseUrl}/auth/login`); await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username); await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password); await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 15_000 }); const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 60_000 });
await page.locator("css=button[type='submit']").click(); await page.locator("css=button[type='submit']").click();
await signedIn; await signedIn;
@@ -86,7 +86,7 @@ describe("lazy widget application graph", () => {
const clockWidget = page.locator( const clockWidget = page.locator(
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`, `[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
); );
await expect(clockWidget).toBeVisible({ timeout: 15_000 }); await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, { await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
timeout: 15_000, timeout: 15_000,
@@ -109,7 +109,7 @@ describe("lazy widget application graph", () => {
.toBe(`${baseUrl}/`); .toBe(`${baseUrl}/`);
await page.reload(); await page.reload();
await expect(clockWidget).toBeVisible({ timeout: 15_000 }); await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
expect(pageErrors).toEqual([]); expect(pageErrors).toEqual([]);
} finally { } finally {
await browser.close(); await browser.close();
@@ -0,0 +1,63 @@
import { describe, expect, test } from "vitest";
import { getTrackedFeatureProperties } from "./analytics-properties";
describe("getTrackedFeatureProperties", () => {
test("keeps setup telemetry anonymous and strips configuration-shaped properties", () => {
expect(
getTrackedFeatureProperties(
"setup:widget-completed",
{
entryPoint: "board",
outcome: "completed",
elapsedMs: 1200,
hasBoardContext: true,
userId: "supplied-user",
url: "https://private.example",
recordId: "secret-id",
},
"session-user",
),
).toEqual({ entryPoint: "board", outcome: "completed", elapsedMs: 1200, hasBoardContext: true });
});
test("preserves the existing identified contract for non-setup events", () => {
expect(getTrackedFeatureProperties("theme-changed", { theme: "dark" }, "user-1")).toEqual({
theme: "dark",
userId: "user-1",
});
});
test("discards setup values outside the closed telemetry contract", () => {
expect(
getTrackedFeatureProperties(
"setup:widget-completed",
{
entryPoint: "https://private.example",
intent: "secret-id",
outcome: "credential",
elapsedMs: Number.POSITIVE_INFINITY,
hasBoardContext: "true",
canResolveInline: 1,
},
"session-user",
),
).toEqual({});
});
test("keeps known widget intents and bounded elapsed times", () => {
expect(
getTrackedFeatureProperties(
"setup:widget-completed",
{ entryPoint: "board", intent: "weather", outcome: "completed", elapsedMs: 0, canResolveInline: false },
"session-user",
),
).toEqual({ entryPoint: "board", intent: "weather", outcome: "completed", elapsedMs: 0, canResolveInline: false });
});
test("keeps the app intent used by Universal Create", () => {
expect(
getTrackedFeatureProperties("setup:intent-selected", { entryPoint: "header", intent: "app" }, "user-1"),
).toEqual({ entryPoint: "header", intent: "app" });
});
});
@@ -0,0 +1,39 @@
import { widgetKinds } from "@homarr/definitions";
const setupEntryPoints = new Set(["header", "spotlight", "board", "assistant", "docker", "management"]);
const setupIntents = new Set([
...widgetKinds,
"widget",
"app",
"integration",
"container",
"board",
"workshop",
"customWidget",
"add-compatible-widget",
]);
const setupOutcomes = new Set(["completed", "blocked", "continued"]);
const maximumSetupElapsedMs = 24 * 60 * 60 * 1_000;
const isValidSetupProperty = (key: string, value: unknown) => {
if (key === "entryPoint") return typeof value === "string" && setupEntryPoints.has(value);
if (key === "intent") return typeof value === "string" && setupIntents.has(value);
if (key === "outcome") return typeof value === "string" && setupOutcomes.has(value);
if (key === "elapsedMs") {
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= maximumSetupElapsedMs;
}
if (key === "hasBoardContext" || key === "canResolveInline") return typeof value === "boolean";
return false;
};
export const getTrackedFeatureProperties = (
feature: string,
properties: Record<string, unknown> | undefined,
userId: string,
) => {
if (!feature.startsWith("setup:")) return { ...properties, userId };
return Object.fromEntries(
Object.entries(properties ?? {}).filter(([key, value]) => isValidSetupProperty(key, value)),
);
};
+6 -4
View File
@@ -7,6 +7,7 @@ import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { getTrackedFeatureProperties } from "./analytics-properties";
const getInstanceId = async (): Promise<string | null> => { const getInstanceId = async (): Promise<string | null> => {
if (env.NO_EXTERNAL_CONNECTION) return null; if (env.NO_EXTERNAL_CONNECTION) return null;
@@ -34,10 +35,11 @@ export const analyticsRouter = createTRPCRouter({
const instanceId = await getInstanceId(); const instanceId = await getInstanceId();
if (!instanceId) return; if (!instanceId) return;
trackEvent(instanceId, `feature:${input.feature}`, { trackEvent(
...input.properties, instanceId,
userId: ctx.session.user.id, `feature:${input.feature}`,
}); getTrackedFeatureProperties(input.feature, input.properties, ctx.session.user.id),
);
}), }),
heartbeat: protectedProcedure.mutation(async ({ ctx }) => { heartbeat: protectedProcedure.mutation(async ({ ctx }) => {
+68 -80
View File
@@ -562,13 +562,15 @@ export const boardRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId }. Requires board-create permission", "Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId, name, layoutId }. Requires board-create permission",
}, },
}) })
.input(boardCreateSchema) .input(boardCreateSchema)
.output(z.object({ boardId: z.string() })) .output(z.object({ boardId: z.string(), name: z.string(), layoutId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const boardId = createId(); const boardId = createId();
const mobileLayoutId = createId();
const baseLayoutId = createId();
const user = await ctx.db.query.users.findFirst({ const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id), where: eq(users.id, ctx.session.user.id),
@@ -594,7 +596,7 @@ export const boardRouter = createTRPCRouter({
}); });
createBoardCollection.layouts.push( createBoardCollection.layouts.push(
{ {
id: createId(), id: mobileLayoutId,
name: "Mobile", name: "Mobile",
columnCount: 3, columnCount: 3,
leftGutterColumnCount: 0, leftGutterColumnCount: 0,
@@ -604,7 +606,7 @@ export const boardRouter = createTRPCRouter({
boardId, boardId,
}, },
{ {
id: createId(), id: baseLayoutId,
name: "Base", name: "Base",
columnCount: input.columnCount, columnCount: input.columnCount,
leftGutterColumnCount: 0, leftGutterColumnCount: 0,
@@ -621,7 +623,7 @@ export const boardRouter = createTRPCRouter({
await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id)); await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id));
} }
return { boardId }; return { boardId, name: input.name, layoutId: baseLayoutId };
}), }),
duplicateBoard: permissionRequiredProcedure duplicateBoard: permissionRequiredProcedure
.requiresPermission("board-create") .requiresPermission("board-create")
@@ -747,19 +749,17 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections
.flatMap((section) => .flatMap((section) =>
section.layouts.map( section.layouts.map((layoutSection): InferInsertModel<typeof sectionLayouts> => ({
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({ ...layoutSection,
...layoutSection, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion layoutId: layoutsMap.get(layoutSection.layoutId)!,
layoutId: layoutsMap.get(layoutSection.layoutId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion sectionId: sectionMap.get(layoutSection.sectionId)!,
sectionId: sectionMap.get(layoutSection.sectionId)!, parentSectionId: layoutSection.parentSectionId
parentSectionId: layoutSection.parentSectionId ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion sectionMap.get(layoutSection.parentSectionId)!
sectionMap.get(layoutSection.parentSectionId)! : layoutSection.parentSectionId,
: layoutSection.parentSectionId, })),
}),
),
) )
.concat( .concat(
(generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({ (generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
@@ -776,13 +776,11 @@ export const boardRouter = createTRPCRouter({
); );
const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap( const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap(
(section) => (section) =>
section.collapseStates.map( section.collapseStates.map((collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
(collapseState): InferInsertModel<typeof sectionCollapseStates> => ({ ...collapseState,
...collapseState, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion sectionId: sectionMap.get(collapseState.sectionId)!,
sectionId: sectionMap.get(collapseState.sectionId)!, })),
}),
),
); );
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()])); const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
@@ -797,17 +795,15 @@ export const boardRouter = createTRPCRouter({
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems
.flatMap((item) => .flatMap((item) =>
item.layouts.map( item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
(layoutSection): InferInsertModel<typeof itemLayouts> => ({ ...layoutSection,
...layoutSection, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion sectionId: sectionMap.get(layoutSection.sectionId)!,
sectionId: sectionMap.get(layoutSection.sectionId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion itemId: itemMap.get(layoutSection.itemId)!,
itemId: itemMap.get(layoutSection.itemId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion layoutId: layoutsMap.get(layoutSection.layoutId)!,
layoutId: layoutsMap.get(layoutSection.layoutId)!, })),
}),
),
) )
.concat( .concat(
(generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({ (generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
@@ -1527,17 +1523,15 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert = addedSections const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container") .filter((section) => section.kind === "container")
.flatMap((section) => .flatMap((section) =>
section.layouts.map( section.layouts.map((sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({ layoutId: sectionLayout.layoutId,
layoutId: sectionLayout.layoutId, sectionId: section.id,
sectionId: section.id, parentSectionId: sectionLayout.parentSectionId,
parentSectionId: sectionLayout.parentSectionId, height: sectionLayout.height,
height: sectionLayout.height, width: sectionLayout.width,
width: sectionLayout.width, xOffset: sectionLayout.xOffset,
xOffset: sectionLayout.xOffset, yOffset: sectionLayout.yOffset,
yOffset: sectionLayout.yOffset, })),
}),
),
); );
if (sectionLayoutsToInsert.length > 0) { if (sectionLayoutsToInsert.length > 0) {
@@ -1559,17 +1553,15 @@ export const boardRouter = createTRPCRouter({
); );
await transaction.insert(schema.itemLayouts).values( await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) => addedItems.flatMap((item) =>
item.layouts.map( item.layouts.map((layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({ layoutId: layoutSection.layoutId,
layoutId: layoutSection.layoutId, sectionId: layoutSection.sectionId,
sectionId: layoutSection.sectionId, itemId: item.id,
itemId: item.id, height: layoutSection.height,
height: layoutSection.height, width: layoutSection.width,
width: layoutSection.width, xOffset: layoutSection.xOffset,
xOffset: layoutSection.xOffset, yOffset: layoutSection.yOffset,
yOffset: layoutSection.yOffset, })),
}),
),
), ),
); );
} }
@@ -1727,17 +1719,15 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert = addedSections const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container") .filter((section) => section.kind === "container")
.flatMap((section) => .flatMap((section) =>
section.layouts.map( section.layouts.map((sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({ layoutId: sectionLayout.layoutId,
layoutId: sectionLayout.layoutId, sectionId: section.id,
sectionId: section.id, parentSectionId: sectionLayout.parentSectionId,
parentSectionId: sectionLayout.parentSectionId, height: sectionLayout.height,
height: sectionLayout.height, width: sectionLayout.width,
width: sectionLayout.width, xOffset: sectionLayout.xOffset,
xOffset: sectionLayout.xOffset, yOffset: sectionLayout.yOffset,
yOffset: sectionLayout.yOffset, })),
}),
),
); );
if (sectionLayoutsToInsert.length > 0) { if (sectionLayoutsToInsert.length > 0) {
@@ -1764,17 +1754,15 @@ export const boardRouter = createTRPCRouter({
.insert(itemLayouts) .insert(itemLayouts)
.values( .values(
addedItems.flatMap((item) => addedItems.flatMap((item) =>
item.layouts.map( item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
(layoutSection): InferInsertModel<typeof itemLayouts> => ({ layoutId: layoutSection.layoutId,
layoutId: layoutSection.layoutId, sectionId: layoutSection.sectionId,
sectionId: layoutSection.sectionId, itemId: item.id,
itemId: item.id, height: layoutSection.height,
height: layoutSection.height, width: layoutSection.width,
width: layoutSection.width, xOffset: layoutSection.xOffset,
xOffset: layoutSection.xOffset, yOffset: layoutSection.yOffset,
yOffset: layoutSection.yOffset, })),
}),
),
), ),
) )
.run(); .run();
@@ -0,0 +1,246 @@
import type { Database } from "@homarr/db";
import type { IntegrationKind } from "@homarr/definitions";
import {
buildDockerServiceUrlCandidates,
getIntegrationDefaultPort,
matchDockerService,
normalizeDockerServiceUrl,
} from "@homarr/definitions";
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
export type DockerReconciliationState = "newRecognized" | "newApp" | "represented" | "linked" | "moved";
export type DockerReconciliationNextAction =
| "setupIntegration"
| "createApp"
| "viewRepresentation"
| "reviewIntegration";
export const getDockerReconciliationAsync = async (db: Database) => {
const [{ data, timestamp }, integrations, apps] = await Promise.all([
dockerContainersRequestHandler.handler({}).getDataAsync(),
db.query.integrations.findMany({
with: {
items: { columns: { itemId: true } },
secrets: { columns: { kind: true } },
},
}),
db.query.apps.findMany(),
]);
const normalizedIntegrations = integrations.map((integration) => ({
integration,
normalizedUrl: normalizeDockerServiceUrl(integration.url),
}));
const normalizedApps = apps.map((app) => ({ app, normalizedUrl: normalizeDockerServiceUrl(app.href) }));
const containerNameCounts = new Map<string, number>();
for (const container of data.containers) {
const name = normalizeName(container.name);
containerNameCounts.set(name, (containerNameCounts.get(name) ?? 0) + 1);
}
const candidates = data.containers.map((container) => {
const match = matchDockerService(container);
const urlCandidates = buildDockerServiceUrlCandidates({
containerName: container.name,
endpointHost: container.host,
ports: container.ports,
preferredPort: match ? getIntegrationDefaultPort(match.kind) : undefined,
});
const candidateUrls = new Set(
urlCandidates.map(({ url }) => normalizeDockerServiceUrl(url)).filter((url) => url !== null),
);
const uniqueName = containerNameCounts.get(normalizeName(container.name)) === 1;
const sameKindIntegrations = match
? normalizedIntegrations.filter(({ integration }) => integration.kind === match.kind)
: [];
const exactIntegrationMatches = sameKindIntegrations
.filter(({ normalizedUrl }) => normalizedUrl !== null && candidateUrls.has(normalizedUrl))
.map(({ integration }) => integration);
const nameIntegrationMatches = uniqueName
? sameKindIntegrations
.filter(({ integration }) => normalizeName(integration.name) === normalizeName(container.name))
.map(({ integration }) => integration)
: [];
const exactIntegration = exactIntegrationMatches.length === 1 ? exactIntegrationMatches[0] : undefined;
const nameIntegration =
exactIntegrationMatches.length === 0 && nameIntegrationMatches.length === 1
? nameIntegrationMatches[0]
: undefined;
const integration = exactIntegration ?? nameIntegration;
const exactAppMatches = normalizedApps
.filter(({ normalizedUrl }) => normalizedUrl !== null && candidateUrls.has(normalizedUrl))
.map(({ app }) => app);
const nameAppMatches = uniqueName
? apps.filter(({ name }) => normalizeName(name) === normalizeName(container.name))
: [];
const exactApp = exactAppMatches.length === 1 ? exactAppMatches[0] : undefined;
const nameApp = exactAppMatches.length === 0 && nameAppMatches.length === 1 ? nameAppMatches[0] : undefined;
const linkedApp = integration?.appId ? apps.find(({ id }) => id === integration.appId) : undefined;
const app = linkedApp ?? exactApp ?? nameApp;
const exactUrl = Boolean(exactIntegration || exactApp);
const linked = Boolean(integration?.appId && linkedApp);
const integrationMoved = Boolean(integration && !exactIntegration);
const integrationAmbiguous =
exactIntegrationMatches.length > 1 || (exactIntegrationMatches.length === 0 && nameIntegrationMatches.length > 1);
const appAmbiguous =
!linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1));
const ambiguous = integrationAmbiguous || appAmbiguous;
const state: DockerReconciliationState = integrationMoved
? "moved"
: linked
? "linked"
: integration || app
? "represented"
: match
? "newRecognized"
: "newApp";
return {
candidateKey: container.resourceId,
endpointId: container.endpointId,
endpointName: container.endpointName,
nativeId: container.id,
container,
match,
urlCandidates,
state,
nextAction: integrationAmbiguous
? ("reviewIntegration" as const)
: appAmbiguous
? ("viewRepresentation" as const)
: nextActionForState(state),
representation: {
integration: integration
? {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
appId: integration.appId,
itemCount: integration.items.length,
configuredSecretKinds: integration.secrets.map(({ kind }) => kind),
}
: null,
app: app ? { id: app.id, name: app.name, href: app.href } : null,
signals: {
exactUrl,
exactIntegrationUrl: Boolean(exactIntegration),
exactAppUrl: Boolean(exactApp),
nameMatch: Boolean(nameIntegration || nameApp),
linked,
ambiguous,
},
},
};
});
return { candidates, endpoints: data.endpoints, timestamp };
};
export const getDockerServiceHealthAsync = async (db: Database) => {
const reconciliation = await getDockerReconciliationAsync(db);
return {
timestamp: reconciliation.timestamp,
endpoints: reconciliation.endpoints.map((endpoint) => ({
key: `endpoint:${endpoint.id}`,
identity: { endpointId: endpoint.id, name: endpoint.name },
layer: "docker" as const,
status: endpoint.status,
nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const),
})),
services: reconciliation.candidates.map((candidate) => ({
key: candidate.candidateKey,
identity: {
endpointId: candidate.endpointId,
nativeId: candidate.nativeId,
name: candidate.container.name,
integration: candidate.representation.integration,
app: candidate.representation.app,
},
layers: [
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
{
layer: "integrationConfiguration" as const,
status: integrationHealthStatus(
candidate.state,
candidate.match?.kind,
Boolean(candidate.representation.integration),
),
nextAction:
candidate.nextAction === "reviewIntegration"
? ("reviewIntegration" as const)
: candidate.state === "newRecognized"
? ("setupIntegration" as const)
: ("none" as const),
},
{
layer: "authentication" as const,
status: "notObserved" as const,
nextAction: candidate.representation.integration
? ("testConnection" as const)
: candidate.nextAction === "reviewIntegration"
? ("reviewIntegration" as const)
: candidate.match
? ("setupIntegration" as const)
: ("none" as const),
},
{
layer: "apiRequest" as const,
status: "notObserved" as const,
nextAction: candidate.representation.integration
? ("openIntegrationDiagnostics" as const)
: ("none" as const),
},
{
layer: "appRepresentation" as const,
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
nextAction: candidate.representation.app
? ("none" as const)
: candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation"
? ("viewRepresentation" as const)
: ("createApp" as const),
},
{
layer: "widgetConfiguration" as const,
status: candidate.representation.integration
? candidate.representation.integration.itemCount > 0
? ("linked" as const)
: ("unused" as const)
: ("notApplicable" as const),
nextAction:
candidate.representation.integration && candidate.representation.integration.itemCount === 0
? ("addWidget" as const)
: ("none" as const),
},
{
layer: "widgetQuery" as const,
status: "notObserved" as const,
nextAction:
candidate.representation.integration && candidate.representation.integration.itemCount > 0
? ("openBoard" as const)
: ("none" as const),
},
],
status: candidate.state,
nextAction: candidate.nextAction,
})),
};
};
const normalizeName = (value: string) => value.trim().toLowerCase();
const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => {
if (state === "newRecognized") return "setupIntegration";
if (state === "newApp") return "createApp";
if (state === "moved") return "reviewIntegration";
return "viewRepresentation";
};
const integrationHealthStatus = (
state: DockerReconciliationState,
kind: IntegrationKind | undefined,
hasIntegration: boolean,
) => {
if (!kind) return "notApplicable" as const;
if (state === "moved") return "changed" as const;
return hasIntegration ? ("configured" as const) : ("missing" as const);
};
+125 -72
View File
@@ -2,96 +2,140 @@ import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker"; import type { ContainerState, DockerContainerTarget, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker"; import { DockerSingleton } from "@homarr/docker";
import { import {
dockerContainersRequestHandler, dockerContainersRequestHandler,
findDockerContainerAsync,
getContainerLogsAsync, getContainerLogsAsync,
getDockerEndpointsAsync,
hasDockerEndpointCapability,
streamContainerLogsAsync, streamContainerLogsAsync,
} from "@homarr/request-handler/docker"; } from "@homarr/request-handler/docker";
import { dockerMiddleware } from "../../middlewares/docker"; import { dockerMiddleware } from "../../middlewares/docker";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { throwIfDockerOperationsFailed } from "./docker-operation-results"; import { getDockerReconciliationAsync, getDockerServiceHealthAsync } from "./docker-reconciliation";
const dockerContainerTargetSchema = z.object({
endpointId: z.string().min(1),
id: z.string().min(1),
});
const dockerContainerTargetsSchema = z.array(dockerContainerTargetSchema).min(1).max(100);
const normalizeGetContainersInput = (input: { endpointIds?: string[] } | undefined) => {
if (!input?.endpointIds || input.endpointIds.length === 0) return {};
return input;
};
export const dockerRouter = createTRPCRouter({ export const dockerRouter = createTRPCRouter({
reconcileServices: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Reconcile Docker container inventory with Homarr integrations and apps, including URL matches and recommended setup or repair actions. Requires admin permission.",
},
})
.concat(dockerMiddleware())
.query(async ({ ctx }) => await getDockerReconciliationAsync(ctx.db)),
getServiceHealth: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get Docker endpoint and discovered-service health projected across Docker, integration, app, and widget setup layers. Requires admin permission.",
},
})
.concat(dockerMiddleware())
.query(async ({ ctx }) => await getDockerServiceHealthAsync(ctx.db)),
refreshInventory: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Invalidate the cached Docker inventory so the next Docker query performs a fresh user-requested discovery. Requires admin permission.",
},
})
.concat(dockerMiddleware())
.mutation(() => {
DockerSingleton.reset();
dockerContainersRequestHandler.invalidateCache();
}),
getContainers: permissionRequiredProcedure getContainers: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.meta({ .meta({
mcp: { mcp: {
enabled: true, enabled: true,
description: "List all Docker containers with their state, image, CPU/memory usage, and ports", description:
"List Docker containers with their state, image, CPU/memory usage, and ports. Optionally filter by endpoint IDs; omitted or empty means all endpoints.",
}, },
}) })
.concat(dockerMiddleware()) .concat(dockerMiddleware())
.query(async () => { .input(z.object({ endpointIds: z.array(z.string().min(1)).max(100).optional() }).optional())
const innerHandler = dockerContainersRequestHandler.handler({}); .query(async ({ input }) => {
const innerHandler = dockerContainersRequestHandler.handler(normalizeGetContainersInput(input));
const result = await innerHandler.getDataAsync(); const result = await innerHandler.getDataAsync();
const { data, timestamp } = result; const { data, timestamp } = result;
return { return {
containers: data satisfies DockerContainer[], containers: data.containers satisfies DockerContainer[],
endpoints: data.endpoints,
timestamp, timestamp,
}; };
}), }),
getEndpoints: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description: "List configured Docker endpoints with their availability and capabilities",
},
})
.concat(dockerMiddleware())
.query(async () => getDockerEndpointsAsync()),
startAll: permissionRequiredProcedure startAll: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.meta({ .meta({
mcp: { mcp: {
enabled: true, enabled: true,
description: "Start Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)", description: "Start Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
}, },
}) })
.concat(dockerMiddleware()) .concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) })) .input(z.object({ targets: dockerContainerTargetsSchema }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const results = await Promise.allSettled( return await performDockerContainerActionsAsync(input.targets, "start");
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.start();
}),
);
throwIfDockerOperationsFailed(results);
}), }),
stopAll: permissionRequiredProcedure stopAll: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.meta({ .meta({
mcp: { mcp: {
enabled: true, enabled: true,
description: "Stop Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)", description: "Stop Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
}, },
}) })
.concat(dockerMiddleware()) .concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) })) .input(z.object({ targets: dockerContainerTargetsSchema }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const results = await Promise.allSettled( return await performDockerContainerActionsAsync(input.targets, "stop");
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.stop();
}),
);
throwIfDockerOperationsFailed(results);
}), }),
restartAll: permissionRequiredProcedure restartAll: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.meta({ .meta({
mcp: { mcp: {
enabled: true, enabled: true,
description: description: "Restart Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
"Restart Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
}, },
}) })
.concat(dockerMiddleware()) .concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) })) .input(z.object({ targets: dockerContainerTargetsSchema }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const results = await Promise.allSettled( return await performDockerContainerActionsAsync(input.targets, "restart");
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.restart();
}),
);
throwIfDockerOperationsFailed(results);
}), }),
removeAll: permissionRequiredProcedure removeAll: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
@@ -99,19 +143,13 @@ export const dockerRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Remove/delete Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)", "Remove/delete Docker containers. REQUIRED: targets (endpointId and id pairs from docker_getContainers)",
}, },
}) })
.concat(dockerMiddleware()) .concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) })) .input(z.object({ targets: dockerContainerTargetsSchema }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const results = await Promise.allSettled( return await performDockerContainerActionsAsync(input.targets, "remove");
input.ids.map(async (id) => {
const container = await getContainerOrThrowAsync(id);
await container.remove();
}),
);
throwIfDockerOperationsFailed(results);
}), }),
logs: permissionRequiredProcedure logs: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
@@ -119,18 +157,19 @@ export const dockerRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Fetch logs from a Docker container. REQUIRED: id (container ID from docker_getContainers). OPTIONAL: tail (number 1-1000, default 200)", "Fetch logs from a Docker container. REQUIRED: endpointId and id from docker_getContainers. OPTIONAL: tail (number 1-1000, default 200)",
}, },
}) })
.concat(dockerMiddleware()) .concat(dockerMiddleware())
.input( .input(
z.object({ z.object({
id: z.string(), ...dockerContainerTargetSchema.shape,
tail: z.number().min(1).max(1000).optional(), tail: z.number().min(1).max(1000).optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200); assertDockerEndpointCapability(input.endpointId, "logs");
const logs = await getContainerLogsAsync(input, input.tail ?? 200);
if (!logs) { if (!logs) {
throw new TRPCError({ throw new TRPCError({
@@ -148,11 +187,12 @@ export const dockerRouter = createTRPCRouter({
.concat(dockerMiddleware()) .concat(dockerMiddleware())
.input( .input(
z.object({ z.object({
id: z.string(), ...dockerContainerTargetSchema.shape,
tail: z.number().min(1).max(1000).optional(), tail: z.number().min(1).max(1000).optional(),
}), }),
) )
.subscription(({ input }) => { .subscription(({ input }) => {
assertDockerEndpointCapability(input.endpointId, "logs");
return observable<string>((emit) => { return observable<string>((emit) => {
let cleanupFn: (() => void) | undefined; let cleanupFn: (() => void) | undefined;
let isSubscribed = true; let isSubscribed = true;
@@ -160,7 +200,7 @@ export const dockerRouter = createTRPCRouter({
const initializeStreamAsync = async () => { const initializeStreamAsync = async () => {
try { try {
cleanupFn = await streamContainerLogsAsync( cleanupFn = await streamContainerLogsAsync(
input.id, input,
input.tail ?? 200, input.tail ?? 200,
(data) => { (data) => {
if (isSubscribed) { if (isSubscribed) {
@@ -200,36 +240,49 @@ export const dockerRouter = createTRPCRouter({
}), }),
}); });
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { type DockerContainerAction = "start" | "stop" | "restart" | "remove";
const container = instance.getContainer(id);
return await new Promise<Container | null>((resolve) => { export const performDockerContainerActionsAsync = async (
container.inspect((err, data) => { targets: DockerContainerTarget[],
if (err || !data) { action: DockerContainerAction,
resolve(null); ) =>
} else { await Promise.all(
resolve(container); targets.map(async (target) => {
try {
const capability = action === "remove" ? "remove" : "lifecycle";
if (!hasDockerEndpointCapability(target.endpointId, capability)) {
return { target, success: false as const, error: `Endpoint does not permit ${capability} actions` };
}
const container = await findDockerContainerAsync(target, capability);
if (!container) return { target, success: false as const, error: "Container not found" };
if (action === "start") await container.start();
else if (action === "stop") await container.stop();
else if (action === "restart") await container.restart();
else await container.remove();
return { target, success: true as const };
} catch (error) {
return {
target,
success: false as const,
error: error instanceof Error ? error.message : "Docker action failed",
};
} }
}); }),
}); );
};
const getContainerOrThrowAsync = async (id: string) => { const assertDockerEndpointCapability = (
const dockerInstances = DockerSingleton.getInstances(); endpointId: string,
const containers = await Promise.all(dockerInstances.map(({ instance }) => getContainerOrDefaultAsync(instance, id))); capability: "inventory" | "logs" | "lifecycle" | "remove",
const foundContainer = containers.find((container) => container) ?? null; ) => {
if (hasDockerEndpointCapability(endpointId, capability)) return;
if (!foundContainer) { throw new TRPCError({ code: "FORBIDDEN", message: `Docker endpoint does not permit ${capability} access` });
throw new TRPCError({
code: "NOT_FOUND",
message: "Container not found",
});
}
return foundContainer;
}; };
interface DockerContainer { interface DockerContainer {
endpointId: string;
endpointName: string;
resourceId: string;
name: string; name: string;
id: string; id: string;
host: string; host: string;
@@ -90,21 +90,14 @@ export const integrationRouter = createTRPCRouter({
}); });
return integrations return integrations
.map((integration) => { .map((integration) => {
const permissions = integration.userPermissions const permissions = constructIntegrationPermissions(integration, ctx.session);
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return { return {
id: integration.id, id: integration.id,
name: integration.name, name: integration.name,
kind: integration.kind, kind: integration.kind,
url: integration.url, url: integration.url,
permissions: { permissions,
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
}; };
}) })
.toSorted( .toSorted(
@@ -138,21 +131,14 @@ export const integrationRouter = createTRPCRouter({
}); });
return integrationsFromDb return integrationsFromDb
.map((integration) => { .map((integration) => {
const permissions = integration.userPermissions const permissions = constructIntegrationPermissions(integration, ctx.session);
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return { return {
id: integration.id, id: integration.id,
name: integration.name, name: integration.name,
kind: integration.kind, kind: integration.kind,
url: integration.url, url: integration.url,
permissions: { permissions,
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
}; };
}) })
.toSorted( .toSorted(
@@ -189,21 +175,14 @@ export const integrationRouter = createTRPCRouter({
}); });
return integrationsFromDb return integrationsFromDb
.map((integration) => { .map((integration) => {
const permissions = integration.userPermissions const permissions = constructIntegrationPermissions(integration, ctx.session);
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return { return {
id: integration.id, id: integration.id,
name: integration.name, name: integration.name,
kind: integration.kind, kind: integration.kind,
url: integration.url, url: integration.url,
permissions: { permissions,
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
}; };
}) })
.toSorted( .toSorted(
@@ -301,7 +280,7 @@ export const integrationRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it.", "Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it. Returns integration details and appId, which is null when no app is linked or created.",
}, },
}) })
.input(integrationCreateSchema) .input(integrationCreateSchema)
@@ -368,6 +347,16 @@ export const integrationRouter = createTRPCRouter({
kind: input.kind, kind: input.kind,
url: input.url, url: input.url,
}); });
return {
integration: {
id: integrationId,
name: input.name,
kind: input.kind,
url: input.url,
},
appId,
};
}), }),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -0,0 +1,148 @@
import { KubeConfig } from "@kubernetes/client-node";
import { describe, expect, test, vi } from "vitest";
import { KubernetesClientRegistry, KubernetesContextNotFoundError } from "./kubernetes-client";
const createRegistry = () => {
const kubeConfig = new KubeConfig();
const contextNames = ["home", "remote", "metrics-degraded"];
kubeConfig.loadFromOptions({
clusters: contextNames.map((name) => ({ name, server: `https://${name}.example` })),
users: contextNames.map((name) => ({ name, token: `${name}-token` })),
contexts: contextNames.map((name) => ({ name, cluster: name, user: name })),
currentContext: "home",
});
return new KubernetesClientRegistry(kubeConfig);
};
describe("KubernetesClientRegistry", () => {
test("isolates duplicate resource names by context", async () => {
const registry = createRegistry();
const home = registry.getClient("home");
const remote = registry.getClient("remote");
vi.spyOn(home.coreApi, "listNamespace").mockResolvedValue({ items: [{ metadata: { name: "default" } }] } as never);
vi.spyOn(remote.coreApi, "listNamespace").mockResolvedValue({
items: [{ metadata: { name: "default" } }],
} as never);
const [homeNamespaces, remoteNamespaces] = await Promise.all([
home.coreApi.listNamespace(),
remote.coreApi.listNamespace(),
]);
expect(homeNamespaces.items[0]?.metadata?.name).toBe("default");
expect(remoteNamespaces.items[0]?.metadata?.name).toBe("default");
expect(home).not.toBe(remote);
expect(home.kubeConfig.getCurrentContext()).toBe("home");
expect(remote.kubeConfig.getCurrentContext()).toBe("remote");
});
test("lists unavailable and metrics-degraded contexts without failing healthy contexts", async () => {
const registry = createRegistry();
for (const contextId of ["home", "remote", "metrics-degraded"]) {
const client = registry.getClient(contextId);
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
vi.spyOn(client.metricsProbeApi, "listClusterCustomObject").mockResolvedValue({ items: [] } as never);
}
registry.getClient("remote").versionApi.getCode = vi.fn().mockRejectedValue(new Error("offline"));
registry.getClient("metrics-degraded").metricsProbeApi.listClusterCustomObject = vi
.fn()
.mockRejectedValue(new Error("no metrics"));
const result = await registry.getContextsAsync();
expect(result.defaultContextId).toBe("home");
expect(result.contexts).toEqual([
{ contextId: "home", name: "home", status: "available", metricsStatus: "available", isDefault: true },
{ contextId: "remote", name: "remote", status: "unavailable", metricsStatus: "unknown", isDefault: false },
{
contextId: "metrics-degraded",
name: "metrics-degraded",
status: "degraded",
metricsStatus: "unavailable",
isDefault: false,
},
]);
});
test("times out an unresponsive context without delaying healthy contexts", async () => {
vi.useFakeTimers();
try {
const registry = createRegistry();
for (const contextId of ["home", "remote", "metrics-degraded"]) {
const client = registry.getClient(contextId);
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
vi.spyOn(client.metricsProbeApi, "listClusterCustomObject").mockResolvedValue({ items: [] } as never);
}
let observedSignal: AbortSignal | undefined;
vi.spyOn(registry.getClient("remote").versionApi, "getCode").mockImplementation(async (_request, options) => {
const middleware = (
options as { middleware?: { pre?: (request: unknown) => { toPromise: () => Promise<unknown> } }[] }
).middleware?.[0];
await middleware?.pre?.({ setSignal: (signal: AbortSignal) => (observedSignal = signal) }).toPromise();
return await new Promise<never>(() => undefined);
});
const resultPromise = registry.getContextsAsync(50);
await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;
expect(observedSignal?.aborted).toBe(true);
expect(result.contexts).toEqual(
expect.arrayContaining([
expect.objectContaining({ contextId: "home", status: "available" }),
expect.objectContaining({ contextId: "remote", status: "unavailable" }),
]),
);
} finally {
vi.useRealTimers();
}
});
test("aborts a timed-out node metrics request", async () => {
vi.useFakeTimers();
try {
const client = createRegistry().getClient("home");
let observedSignal: AbortSignal | undefined;
vi.spyOn(client.metricsProbeApi, "listClusterCustomObject").mockImplementation(async (_request, options) => {
const middleware = (
options as { middleware?: { pre?: (request: unknown) => { toPromise: () => Promise<unknown> } }[] }
).middleware?.[0];
await middleware?.pre?.({ setSignal: (signal: AbortSignal) => (observedSignal = signal) }).toPromise();
return await new Promise<never>(() => undefined);
});
const resultPromise = client.getNodeMetricsAsync(50);
const rejection = expect(resultPromise).rejects.toThrow("Kubernetes context probe timed out");
await vi.advanceTimersByTimeAsync(50);
await rejection;
expect(observedSignal?.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
test("rejects unknown context IDs", () => {
expect(() => createRegistry().getClient("missing")).toThrow(KubernetesContextNotFoundError);
});
test("uses the first configured context when current-context is empty", async () => {
const kubeConfig = new KubeConfig();
kubeConfig.loadFromOptions({
clusters: [{ name: "first", server: "https://first.example" }],
users: [{ name: "first", token: "token" }],
contexts: [{ name: "first", cluster: "first", user: "first" }],
currentContext: "",
});
const registry = new KubernetesClientRegistry(kubeConfig);
const client = registry.getClient("first");
vi.spyOn(client.versionApi, "getCode").mockResolvedValue({} as never);
vi.spyOn(client.metricsProbeApi, "listClusterCustomObject").mockResolvedValue({ items: [] } as never);
const result = await registry.getContextsAsync();
expect(result.defaultContextId).toBe("first");
expect(result.contexts[0]).toEqual(expect.objectContaining({ contextId: "first", isDefault: true }));
});
});
@@ -1,71 +1,196 @@
import * as fs from "fs"; import * as fs from "fs";
import { CoreV1Api, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@kubernetes/client-node"; import type { NodeMetricsList, RequestContext, ResponseContext } from "@kubernetes/client-node";
import { CoreV1Api, CustomObjectsApi, KubeConfig, NetworkingV1Api, VersionApi } from "@kubernetes/client-node";
import { of } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
import { env } from "../../env"; import { env } from "../../env";
export interface KubernetesContextStatus {
contextId: string;
name: string;
status: "available" | "degraded" | "unavailable";
metricsStatus: "available" | "unavailable" | "unknown";
isDefault: boolean;
}
export const kubernetesContextProbeTimeoutMs = 5_000;
const withKubernetesProbeTimeoutAsync = async <T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
) => {
const controller = new AbortController();
const timeoutError = new Error("Kubernetes context probe timed out");
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
};
const abortableKubernetesRequest = (signal: AbortSignal) => ({
middleware: [
{
pre: (request: RequestContext) => {
request.setSignal(signal);
return of(request);
},
post: (response: ResponseContext) => of(response),
},
],
});
export class KubernetesClient { export class KubernetesClient {
private static instance: KubernetesClient | null = null; private static registry: KubernetesClientRegistry | null = null;
public kubeConfig: KubeConfig; public kubeConfig: KubeConfig;
public coreApi: CoreV1Api; public coreApi: CoreV1Api;
public networkingApi: NetworkingV1Api; public networkingApi: NetworkingV1Api;
public metricsApi: Metrics; public metricsProbeApi: CustomObjectsApi;
public versionApi: VersionApi; public versionApi: VersionApi;
private constructor() { public constructor(kubeConfig: KubeConfig) {
this.kubeConfig = new KubeConfig(); this.kubeConfig = kubeConfig;
this.coreApi = kubeConfig.makeApiClient(CoreV1Api);
if (process.env.NODE_ENV === "development") { this.networkingApi = kubeConfig.makeApiClient(NetworkingV1Api);
this.kubeConfig.loadFromDefault(); this.metricsProbeApi = kubeConfig.makeApiClient(CustomObjectsApi);
} else { this.versionApi = kubeConfig.makeApiClient(VersionApi);
this.kubeConfig.loadFromCluster();
const currentCluster = this.kubeConfig.getCurrentCluster();
if (!currentCluster) throw new Error("No cluster configuration found");
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
const clusterWithCA = {
...currentCluster,
name: `${currentCluster.name}-service-account`,
caData,
};
const serviceAccountUser = {
name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa",
token,
};
this.kubeConfig.clusters = [];
this.kubeConfig.users = [];
this.kubeConfig.addCluster(clusterWithCA);
this.kubeConfig.addUser(serviceAccountUser);
const currentContext = this.kubeConfig.getCurrentContext();
const originalContext = this.kubeConfig.getContextObject(currentContext);
if (!originalContext) throw new Error("No context found");
const updatedContext = {
...originalContext,
name: `${originalContext.name}-service-account`,
cluster: clusterWithCA.name,
user: serviceAccountUser.name,
};
this.kubeConfig.contexts = [];
this.kubeConfig.addContext(updatedContext);
this.kubeConfig.setCurrentContext(updatedContext.name);
}
this.coreApi = this.kubeConfig.makeApiClient(CoreV1Api);
this.networkingApi = this.kubeConfig.makeApiClient(NetworkingV1Api);
this.metricsApi = new Metrics(this.kubeConfig);
this.versionApi = this.kubeConfig.makeApiClient(VersionApi);
} }
public static getInstance(): KubernetesClient { public async getNodeMetricsAsync(timeoutMs = kubernetesContextProbeTimeoutMs): Promise<NodeMetricsList> {
KubernetesClient.instance ??= new KubernetesClient(); const result: unknown = await withKubernetesProbeTimeoutAsync(
return KubernetesClient.instance; async (signal) =>
await this.metricsProbeApi.listClusterCustomObject(
{ group: "metrics.k8s.io", version: "v1beta1", plural: "nodes" },
abortableKubernetesRequest(signal),
),
timeoutMs,
);
return result as NodeMetricsList;
}
public static getInstance(contextId: string): KubernetesClient {
return KubernetesClient.getRegistry().getClient(contextId);
}
public static getDefaultInstance(): KubernetesClient {
return KubernetesClient.getRegistry().getDefaultClient();
}
public static async getContextsAsync() {
return await KubernetesClient.getRegistry().getContextsAsync();
}
private static getRegistry() {
KubernetesClient.registry ??= new KubernetesClientRegistry(loadKubeConfig());
return KubernetesClient.registry;
} }
} }
export class KubernetesClientRegistry {
private readonly clients = new Map<string, KubernetesClient>();
private readonly defaultContextId: string;
public constructor(kubeConfig: KubeConfig) {
const defaultContextId = kubeConfig.getCurrentContext() || kubeConfig.contexts[0]?.name;
if (!defaultContextId) throw new Error("No Kubernetes context is configured");
this.defaultContextId = defaultContextId;
for (const context of kubeConfig.contexts) {
const contextConfig = new KubeConfig();
contextConfig.loadFromOptions({
clusters: kubeConfig.clusters,
users: kubeConfig.users,
contexts: kubeConfig.contexts,
currentContext: context.name,
});
this.clients.set(context.name, new KubernetesClient(contextConfig));
}
}
public getClient(contextId: string) {
const client = this.clients.get(contextId);
if (!client) throw new KubernetesContextNotFoundError(contextId);
return client;
}
public getDefaultClient() {
return this.getClient(this.defaultContextId);
}
public async getContextsAsync(timeoutMs = kubernetesContextProbeTimeoutMs) {
const contexts = await Promise.all(
[...this.clients.entries()].map(async ([contextId, client]): Promise<KubernetesContextStatus> => {
const [api, metrics] = await Promise.allSettled([
withKubernetesProbeTimeoutAsync(
async (signal) => await client.versionApi.getCode(undefined, abortableKubernetesRequest(signal)),
timeoutMs,
),
client.getNodeMetricsAsync(timeoutMs),
]);
const status =
api.status === "rejected" ? "unavailable" : metrics.status === "rejected" ? "degraded" : "available";
return {
contextId,
name: contextId,
status,
metricsStatus:
api.status === "rejected" ? "unknown" : metrics.status === "rejected" ? "unavailable" : "available",
isDefault: contextId === this.defaultContextId,
};
}),
);
return { contexts, defaultContextId: this.defaultContextId };
}
}
export class KubernetesContextNotFoundError extends Error {
public constructor(contextId: string) {
super(`Kubernetes context '${contextId}' was not found`);
}
}
const loadKubeConfig = () => {
const kubeConfig = new KubeConfig();
if (process.env.NODE_ENV === "development" || process.env.KUBECONFIG) {
kubeConfig.loadFromDefault();
return kubeConfig;
}
kubeConfig.loadFromCluster();
const currentCluster = kubeConfig.getCurrentCluster();
if (!currentCluster) throw new Error("No cluster configuration found");
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8");
const clusterWithCA = { ...currentCluster, name: `${currentCluster.name}-service-account`, caData };
const serviceAccountUser = { name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa", token };
const currentContext = kubeConfig.getContextObject(kubeConfig.getCurrentContext());
if (!currentContext) throw new Error("No context found");
const updatedContext = {
...currentContext,
name: `${currentContext.name}-service-account`,
cluster: clusterWithCA.name,
user: serviceAccountUser.name,
};
kubeConfig.loadFromOptions({
clusters: [clusterWithCA],
users: [serviceAccountUser],
contexts: [updatedContext],
currentContext: updatedContext.name,
});
return kubeConfig;
};
@@ -0,0 +1,17 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { KubernetesClient, KubernetesContextNotFoundError } from "./kubernetes-client";
export const kubernetesContextInput = z.object({ contextId: z.string().min(1) });
export const getKubernetesClient = (contextId: string) => {
try {
return KubernetesClient.getInstance(contextId);
} catch (error) {
if (error instanceof KubernetesContextNotFoundError) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
throw error;
}
};
@@ -0,0 +1,13 @@
import { describe, expect, test } from "vitest";
import { calculateResourcePercentage } from "./resource-percentage";
describe("calculateResourcePercentage", () => {
test("calculates a rounded percentage", () => {
expect(calculateResourcePercentage(1, 3)).toBe(33.33);
});
test("returns zero when no capacity is available", () => {
expect(calculateResourcePercentage(1, 0)).toBe(0);
});
});
@@ -0,0 +1,2 @@
export const calculateResourcePercentage = (used: number, available: number) =>
available > 0 ? Number(((used / available) * 100).toFixed(2)) : 0;
@@ -5,7 +5,8 @@ import type { ClusterResourceCount, KubernetesCluster } from "@homarr/definition
import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; import { kubernetesMiddleware } from "../../../middlewares/kubernetes";
import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc";
import { KubernetesClient } from "../kubernetes-client"; import { getKubernetesClient, kubernetesContextInput } from "../kubernetes-context";
import { calculateResourcePercentage } from "../resource-percentage";
import { CpuResourceParser } from "../resource-parser/cpu-resource-parser"; import { CpuResourceParser } from "../resource-parser/cpu-resource-parser";
import { MemoryResourceParser } from "../resource-parser/memory-resource-parser"; import { MemoryResourceParser } from "../resource-parser/memory-resource-parser";
@@ -13,14 +14,18 @@ export const clusterRouter = createTRPCRouter({
getCluster: permissionRequiredProcedure getCluster: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.concat(kubernetesMiddleware()) .concat(kubernetesMiddleware())
.query(async (): Promise<KubernetesCluster> => { .input(kubernetesContextInput)
const { coreApi, metricsApi, versionApi, kubeConfig } = KubernetesClient.getInstance(); .query(async ({ input }): Promise<KubernetesCluster> => {
const client = getKubernetesClient(input.contextId);
const { coreApi, versionApi, kubeConfig } = client;
try { try {
const versionInfo = await versionApi.getCode(); const [versionInfo, nodes, listPodForAllNamespaces, nodeMetricsClient] = await Promise.all([
const nodes = await coreApi.listNode(); versionApi.getCode(),
const nodeMetricsClient = await metricsApi.getNodeMetrics(); coreApi.listNode(),
const listPodForAllNamespaces = await coreApi.listPodForAllNamespaces(); coreApi.listPodForAllNamespaces(),
client.getNodeMetricsAsync().catch(() => null),
]);
let totalCPUCapacity = 0; let totalCPUCapacity = 0;
let totalCPUAllocatable = 0; let totalCPUAllocatable = 0;
@@ -48,7 +53,7 @@ export const clusterRouter = createTRPCRouter({
totalMemoryAllocatable += memoryAllocatable; totalMemoryAllocatable += memoryAllocatable;
const nodeName = node.metadata?.name; const nodeName = node.metadata?.name;
const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === nodeName); const nodeMetric = nodeMetricsClient?.items.find((metric) => metric.metadata.name === nodeName);
if (nodeMetric) { if (nodeMetric) {
const cpuUsage = cpuResourceParser.parse(nodeMetric.usage.cpu); const cpuUsage = cpuResourceParser.parse(nodeMetric.usage.cpu);
totalCPUUsage += cpuUsage; totalCPUUsage += cpuUsage;
@@ -61,13 +66,11 @@ export const clusterRouter = createTRPCRouter({
const reservedCPU = totalCPUCapacity - totalCPUAllocatable; const reservedCPU = totalCPUCapacity - totalCPUAllocatable;
const reservedMemory = totalMemoryCapacity - totalMemoryAllocatable; const reservedMemory = totalMemoryCapacity - totalMemoryAllocatable;
const reservedCPUPercentage = (reservedCPU / totalCPUCapacity) * 100; const reservedCPUPercentage = calculateResourcePercentage(reservedCPU, totalCPUCapacity);
const reservedMemoryPercentage = (reservedMemory / totalMemoryCapacity) * 100; const reservedMemoryPercentage = calculateResourcePercentage(reservedMemory, totalMemoryCapacity);
const usagePercentageAllocatable = calculateResourcePercentage(totalCPUUsage, totalCPUAllocatable);
const usagePercentageAllocatable = (totalCPUUsage / totalCPUAllocatable) * 100; const usagePercentageMemoryAllocatable = calculateResourcePercentage(totalMemoryUsage, totalMemoryAllocatable);
const usagePercentageMemoryAllocatable = (totalMemoryUsage / totalMemoryAllocatable) * 100; const usedPodsPercentage = calculateResourcePercentage(listPodForAllNamespaces.items.length, totalCapacityPods);
const usedPodsPercentage = (listPodForAllNamespaces.items.length / totalCapacityPods) * 100;
return { return {
name: kubeConfig.getCurrentContext(), name: kubeConfig.getCurrentContext(),
@@ -75,50 +78,59 @@ export const clusterRouter = createTRPCRouter({
kubernetesVersion: versionInfo.gitVersion, kubernetesVersion: versionInfo.gitVersion,
architecture: versionInfo.platform, architecture: versionInfo.platform,
nodeCount: nodes.items.length, nodeCount: nodes.items.length,
metricsAvailable: nodeMetricsClient !== null,
capacity: [ capacity: [
{ {
type: "CPU", type: "CPU",
resourcesStats: [ resourcesStats: [
{ {
percentageValue: Number(reservedCPUPercentage.toFixed(2)), percentageValue: reservedCPUPercentage,
type: "Reserved", type: "Reserved",
capacityUnit: "Cores", capacityUnit: "Cores",
usedValue: Number(reservedCPU.toFixed(2)), usedValue: Number(reservedCPU.toFixed(2)),
maxUsedValue: Number(totalCPUCapacity.toFixed(2)), maxUsedValue: Number(totalCPUCapacity.toFixed(2)),
}, },
{ ...(nodeMetricsClient
percentageValue: Number(usagePercentageAllocatable.toFixed(2)), ? [
type: "Used", {
capacityUnit: "Cores", percentageValue: usagePercentageAllocatable,
usedValue: Number(totalCPUUsage.toFixed(2)), type: "Used" as const,
maxUsedValue: Number(totalCPUAllocatable.toFixed(2)), capacityUnit: "Cores",
}, usedValue: Number(totalCPUUsage.toFixed(2)),
maxUsedValue: Number(totalCPUAllocatable.toFixed(2)),
},
]
: []),
], ],
}, },
{ {
type: "Memory", type: "Memory",
resourcesStats: [ resourcesStats: [
{ {
percentageValue: Number(reservedMemoryPercentage.toFixed(2)), percentageValue: reservedMemoryPercentage,
type: "Reserved", type: "Reserved",
capacityUnit: "GiB", capacityUnit: "GiB",
usedValue: Number(reservedMemory.toFixed(2)), usedValue: Number(reservedMemory.toFixed(2)),
maxUsedValue: Number(totalMemoryCapacity.toFixed(2)), maxUsedValue: Number(totalMemoryCapacity.toFixed(2)),
}, },
{ ...(nodeMetricsClient
percentageValue: Number(usagePercentageMemoryAllocatable.toFixed(2)), ? [
type: "Used", {
capacityUnit: "GiB", percentageValue: usagePercentageMemoryAllocatable,
usedValue: Number(totalMemoryUsage.toFixed(2)), type: "Used" as const,
maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)), capacityUnit: "GiB",
}, usedValue: Number(totalMemoryUsage.toFixed(2)),
maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)),
},
]
: []),
], ],
}, },
{ {
type: "Pods", type: "Pods",
resourcesStats: [ resourcesStats: [
{ {
percentageValue: Number(usedPodsPercentage.toFixed(2)), percentageValue: usedPodsPercentage,
type: "Used", type: "Used",
usedValue: listPodForAllNamespaces.items.length, usedValue: listPodForAllNamespaces.items.length,
maxUsedValue: totalCapacityPods, maxUsedValue: totalCapacityPods,
@@ -137,8 +149,10 @@ export const clusterRouter = createTRPCRouter({
}), }),
getClusterResourceCounts: permissionRequiredProcedure getClusterResourceCounts: permissionRequiredProcedure
.requiresPermission("admin") .requiresPermission("admin")
.query(async (): Promise<ClusterResourceCount[]> => { .concat(kubernetesMiddleware())
const { coreApi, networkingApi } = KubernetesClient.getInstance(); .input(kubernetesContextInput)
.query(async ({ input }): Promise<ClusterResourceCount[]> => {
const { coreApi, networkingApi } = getKubernetesClient(input.contextId);
try { try {
const [pods, ingresses, services, configMaps, namespaces, nodes, secrets, volumes] = await Promise.all([ const [pods, ingresses, services, configMaps, namespaces, nodes, secrets, volumes] = await Promise.all([
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more