Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d150285165 | ||
|
|
679cede4ae | ||
|
|
bd9e0f7565 | ||
|
|
c6d6f43c85 | ||
|
|
482a8c20e3 | ||
|
|
f1295f4d28 | ||
|
|
46461136a0 | ||
|
|
fe657fd79d | ||
|
|
f6d89f03b2 | ||
|
|
b2ff7c1011 | ||
|
|
510845d3b9 | ||
|
|
6d6815a901 | ||
|
|
f10e49fa20 | ||
|
|
bd171bf73b | ||
|
|
1ee519bb7a | ||
|
|
c53852fdb6 | ||
|
|
206905ea2d | ||
|
|
50537d077c | ||
|
|
229a559aff | ||
|
|
4e514ab151 | ||
|
|
960551f573 | ||
|
|
304ea089a2 | ||
|
|
199f6ffc3b | ||
|
|
7e569bff4c | ||
|
|
0ec5f36050 | ||
|
|
8ecdd13cd5 |
No files matched your search
+64
-33
@@ -8,7 +8,7 @@ on:
|
||||
types: [closed]
|
||||
branches: ["**"]
|
||||
push:
|
||||
branches: [main, dev, beta, next, v2]
|
||||
branches: [main, dev, beta, next, v2, release/v2]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -105,18 +105,20 @@ jobs:
|
||||
|
||||
container:
|
||||
name: Container and E2E
|
||||
needs: fast-gate
|
||||
needs: [fast-gate, preview-amd64]
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
!cancelled() && needs.fast-gate.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.preview-build.outputs.digest }}
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
@@ -131,8 +133,18 @@ jobs:
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Reuse amd64 preview image
|
||||
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
|
||||
env:
|
||||
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
|
||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
|
||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
|
||||
|
||||
- name: Build container once
|
||||
if: "!env.ACT"
|
||||
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -147,8 +159,8 @@ jobs:
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
||||
cache-from: type=gha,scope=homarr-linux-amd64
|
||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||
cache-from: type=gha,scope=homarr-e2e
|
||||
cache-to: type=gha,scope=homarr-e2e,mode=max
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
@@ -189,7 +201,10 @@ jobs:
|
||||
run: pnpm exec playwright install-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm test:e2e
|
||||
env:
|
||||
NODE_ENV: development
|
||||
CI: "true"
|
||||
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
|
||||
|
||||
- name: Upload E2E artifacts
|
||||
if: failure() && !env.ACT
|
||||
@@ -199,27 +214,44 @@ jobs:
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Log in to GHCR for preview publication
|
||||
if: |
|
||||
!env.ACT &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
preview-amd64:
|
||||
name: Build amd64 preview image
|
||||
needs: fast-gate
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.preview-build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: "!env.ACT"
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push amd64 preview image by digest
|
||||
- name: Set up Docker Buildx
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push amd64 preview image by digest
|
||||
id: preview-build
|
||||
if: |
|
||||
!env.ACT &&
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
if: "!env.ACT"
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -227,22 +259,20 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
outputs: type=image,"name=${{ env.PREVIEW_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=homarr-linux-amd64
|
||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||
secrets: |
|
||||
TURBO_API=${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
- name: Show Act publication result
|
||||
if: env.ACT
|
||||
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
|
||||
|
||||
preview-arm64:
|
||||
name: Build arm64 preview image
|
||||
needs: fast-gate
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -290,10 +320,11 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [container, preview-arm64]
|
||||
needs: [preview-amd64, preview-arm64]
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -303,7 +334,7 @@ jobs:
|
||||
packages: write
|
||||
pull-requests: read
|
||||
env:
|
||||
PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
PREVIEW_TAG: ${{ github.event_name == 'push' && 'v2' || format('pr-{0}', github.event.pull_request.number || inputs.pr_number) }}
|
||||
steps:
|
||||
- name: Log in to GHCR
|
||||
if: "!env.ACT"
|
||||
@@ -340,7 +371,7 @@ jobs:
|
||||
- name: Publish preview manifest
|
||||
if: "!env.ACT"
|
||||
env:
|
||||
AMD64_DIGEST: ${{ needs.container.outputs.digest }}
|
||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
permissions:
|
||||
statuses: write
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -16,12 +17,20 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
run: |
|
||||
gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
|
||||
-f 'state=success' \
|
||||
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
|
||||
-f 'description=Skipped for non renovate branches' \
|
||||
-f 'context=renovate/stability-days'
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
|
||||
-f 'state=success' \
|
||||
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
|
||||
-f 'description=Skipped for non renovate branches' \
|
||||
-f 'context=renovate/stability-days' \
|
||||
--silent; then
|
||||
exit 0
|
||||
fi
|
||||
echo "status POST failed (attempt $attempt/5), retrying in 5s"
|
||||
sleep 5
|
||||
done
|
||||
exit 1
|
||||
@@ -58,11 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
## Docker
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
||||
| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -122,9 +122,77 @@ services:
|
||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||
```
|
||||
|
||||
For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./docker-ca.pem:/run/secrets/docker-ca.pem:ro
|
||||
environment:
|
||||
DOCKER_ENDPOINTS: >-
|
||||
[
|
||||
{
|
||||
"id": "local",
|
||||
"name": "Local Docker",
|
||||
"kind": "docker",
|
||||
"transport": { "type": "socket", "path": "/var/run/docker.sock" },
|
||||
"capabilities": ["inventory", "logs", "lifecycle", "remove"]
|
||||
},
|
||||
{
|
||||
"id": "remote-read-only",
|
||||
"name": "Production inventory",
|
||||
"kind": "docker",
|
||||
"transport": {
|
||||
"type": "tls",
|
||||
"host": "docker.example.com",
|
||||
"port": 2376,
|
||||
"caPath": "/run/secrets/docker-ca.pem"
|
||||
},
|
||||
"capabilities": ["inventory", "logs"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The stable `id` keeps containers from different endpoints distinct. `name` is shown in the interface, and `kind` can be `docker` or `podman`. Every endpoint must include the `inventory` capability; optionally add `logs`, `lifecycle`, and `remove`. Homarr hides or disables actions the endpoint does not allow and also enforces the same capabilities on the server.
|
||||
|
||||
Supported transports are:
|
||||
|
||||
- `socket`: an absolute in-container `path`
|
||||
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
|
||||
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
|
||||
|
||||
:::warning[Plaintext TCP]
|
||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`.
|
||||
:::
|
||||
|
||||
When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables.
|
||||
|
||||
### Managing your Docker containers within Homarr
|
||||
|
||||
Homarr allows you to interact with Docker containers running on your system.
|
||||
The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
||||
|
||||
Homarr can show a service as:
|
||||
|
||||
- **Ready to connect** when its image or name matches a supported integration
|
||||
- **New service** when it can be added as an app but no integration is recognized
|
||||
- **Already represented** or **Linked** when an app or integration already matches
|
||||
- **Address changed** when a service appears to match an integration but its reachable address has changed
|
||||
|
||||
Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing.
|
||||
|
||||
:::info[Review before changing]
|
||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
|
||||
:::
|
||||
|
||||
Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
||||
|
||||
The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy.
|
||||
|
||||
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
|
||||
|
||||
Each container row has an action menu with the following options:
|
||||
|
||||
- **View logs** - opens a dedicated logs page with a live terminal
|
||||
|
||||
@@ -14,6 +14,8 @@ import { kubernetesIntegration } from ".";
|
||||
|
||||
Homarr provides a user-friendly Kubernetes dashboard that offers an overview of your cluster’s resources in a clear and structured interface. It allows users to view key Kubernetes objects, including `Pods`, `Services`, `Ingresses`, `Nodes`, `ConfigMaps`, `Secrets`, `Namespaces`, and `Volumes`, as well as basic metrics when the Kubernetes Metrics Server is enabled.
|
||||
|
||||
Docker service discovery does not convert Kubernetes resources into Homarr apps or integrations. Kubernetes remains a separate, read-only operational view, so a cluster resource is never changed or linked automatically.
|
||||
|
||||
:::warning[Read only]
|
||||
This integration is read-only, meaning Homarr does not modify or manage cluster resources but serves as an informational tool to enhance visibility into your Kubernetes environment.
|
||||
:::
|
||||
@@ -25,7 +27,7 @@ This integration is read-only, meaning Homarr does not modify or manage cluster
|
||||
Before deploying Homarr in Kubernetes, ensure the following prerequisites are met:
|
||||
|
||||
- Kubernetes (Version >= 1.24.0)
|
||||
- Metrics Server ([Install from here](https://github.com/kubernetes-sigs/metrics-server))
|
||||
- Metrics Server ([install from here](https://github.com/kubernetes-sigs/metrics-server)) is optional. Without it, Homarr still shows cluster inventory, node status, and reserved capacity, and identifies live CPU and memory usage as unavailable.
|
||||
- RBAC Enabled (Required for Homarr Helm chart)
|
||||
|
||||
### Configuring RBAC in Helm Chart
|
||||
@@ -45,6 +47,25 @@ Enabling RBAC in the Helm chart creates the necessary Kubernetes Role-Based Acce
|
||||
- ClusterRole & ClusterRoleBinding: Extends the same permissions cluster-wide for additional flexibility.
|
||||
:::
|
||||
|
||||
### Multiple Kubernetes contexts
|
||||
|
||||
When Homarr runs outside a cluster, set `KUBECONFIG` explicitly so Homarr loads the contexts from the referenced kubeconfig file or files. Mount the kubeconfig read-only and set the standard `KUBECONFIG` environment variable to its path inside the Homarr container:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
volumes:
|
||||
- ./kubeconfig:/app/config/kubeconfig:ro
|
||||
environment:
|
||||
KUBECONFIG: /app/config/kubeconfig
|
||||
```
|
||||
|
||||
The Kubernetes management pages include a context selector. The selected context is stored in the page URL, so each React Query cache entry and every API request remains context-specific. Resources with the same namespace and name in two contexts therefore remain separate.
|
||||
|
||||
Context status is checked independently. If one context is unavailable, the selector still lists healthy contexts. A context is shown as **Metrics unavailable** when its Kubernetes API is reachable but Metrics Server is not; inventory remains available without live CPU and memory usage.
|
||||
|
||||
When Homarr runs inside Kubernetes without `KUBECONFIG`, it keeps using the in-cluster service account as the single default context.
|
||||
|
||||
### Homarr Dashboard Overview
|
||||
|
||||
Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes:
|
||||
|
||||
@@ -86,6 +86,30 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom`
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Docker action payload migration
|
||||
|
||||
Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a
|
||||
breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action.
|
||||
|
||||
Replace the old payload:
|
||||
|
||||
```json
|
||||
{ "ids": ["container-id"] }
|
||||
```
|
||||
|
||||
with endpoint-qualified targets:
|
||||
|
||||
```json
|
||||
{
|
||||
"targets": [{ "endpointId": "local", "id": "container-id" }]
|
||||
}
|
||||
```
|
||||
|
||||
Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action.
|
||||
Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool
|
||||
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
|
||||
Docker or Podman endpoints from targeting the wrong service.
|
||||
|
||||
## Partial integration failures
|
||||
|
||||
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
|
||||
|
||||
@@ -204,6 +204,15 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
||||
Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
|
||||
destinations before the widget is created, so the common flow does not require a second move step.
|
||||
|
||||
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
saved integration changes apply everywhere that connection is used.
|
||||
|
||||
Pointer users can drag from the inert widget area; interactive controls are excluded. Containers use the grab button at their top-left corner.
|
||||
Widgets can be resized from any edge or corner handle. Activate a Container's chrome with its top-left grip before using
|
||||
its resize handles.
|
||||
@@ -218,7 +227,11 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
|
||||
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
|
||||
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
||||
|
||||
<img src={keyboardTileEditingImage} alt="A focused board tile in keyboard editing mode" style={{ maxWidth: "100%" }} />
|
||||
<img
|
||||
src={keyboardTileEditingImage}
|
||||
alt="A focused board tile in keyboard editing mode"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
#### Preview
|
||||
|
||||
|
||||
@@ -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.
|
||||
The test and create button will perform a network request to the application and authenticate if required.
|
||||
|
||||
When you connect a service from a board, Homarr keeps you in the same setup flow. After the connection succeeds, it
|
||||
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
|
||||
widget returns to its settings with the new integration already selected.
|
||||
|
||||
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
|
||||
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
|
||||
changes apply to every app and widget that uses the integration.
|
||||
|
||||
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
|
||||
create one.
|
||||
|
||||
When the connection unlocks useful widgets that are not on the current board yet, the confirmation also shows up to
|
||||
three explainable recommendations. A recommendation is based only on the services you can use and the widget kinds
|
||||
already present on that board. You can open the existing widget picker prefiltered to the recommendation, or dismiss
|
||||
recommendations for that service on that board.
|
||||
|
||||
## Managing permissions
|
||||
|
||||
On the edit page of an integration, you can manage which users / groups can
|
||||
|
||||
+7
-1
@@ -1266,9 +1266,15 @@
|
||||
"properties": {
|
||||
"boardId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"layoutId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["boardId"],
|
||||
"required": ["boardId", "name", "layoutId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"@mantine/form": "catalog:",
|
||||
"@mantine/hooks": "catalog:",
|
||||
"@mantine/modals": "catalog:",
|
||||
"@mantine/notifications": "catalog:",
|
||||
"@mantine/tiptap": "catalog:",
|
||||
"@modelcontextprotocol/server": "catalog:",
|
||||
"@rexa-developer/tiks": "catalog:",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useDisclosure, useMounted } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
@@ -32,7 +32,6 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
const searchParams = useSearchParams();
|
||||
const isError = searchParams.has("error");
|
||||
const router = useRouter();
|
||||
const isMounted = useMounted();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const form = useZodForm(extendedValidation, {
|
||||
initialValues: {
|
||||
@@ -140,7 +139,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
|
||||
{providers.includes("credentials") && (
|
||||
<Stack gap="sm">
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="credentials">
|
||||
<SubmitButton isPending={isPending} form={form} provider="credentials">
|
||||
{t("action.login.label")}
|
||||
</SubmitButton>
|
||||
<PasswordForgottenCollapse username={form.values.name} />
|
||||
@@ -148,7 +147,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("ldap") && (
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="ldap">
|
||||
<SubmitButton isPending={isPending} form={form} provider="ldap">
|
||||
{t("action.login.labelWith", { provider: "LDAP" })}
|
||||
</SubmitButton>
|
||||
)}
|
||||
@@ -159,13 +158,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("oidc") && (
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
variant="light"
|
||||
disabled={!isMounted || isPending}
|
||||
onClick={() => void signInAsync("oidc")}
|
||||
>
|
||||
<Button type="button" fullWidth variant="light" disabled={isPending} onClick={() => void signInAsync("oidc")}>
|
||||
{t("action.login.labelWith", { provider: oidcClientName })}
|
||||
</Button>
|
||||
)}
|
||||
@@ -175,13 +168,12 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
};
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isMounted: boolean;
|
||||
isPending: boolean;
|
||||
form: UseFormReturnType<FormType>;
|
||||
provider: "credentials" | "ldap";
|
||||
}
|
||||
|
||||
const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const isCurrentProviderActive = form.getValues().provider === provider;
|
||||
|
||||
return (
|
||||
@@ -192,7 +184,7 @@ const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsW
|
||||
fullWidth
|
||||
onClick={() => form.setFieldValue("provider", provider)}
|
||||
loading={isPending && isCurrentProviderActive}
|
||||
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
|
||||
disabled={isPending && !isCurrentProviderActive}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||
import { BoardEmptyState } from "~/components/board/board-empty-state";
|
||||
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
@@ -64,6 +66,8 @@ export const ClientBoard = () => {
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useContainerActions } from "~/components/board/sections/container/container-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
|
||||
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
@@ -20,60 +17,41 @@ export default function BoardEditActions() {
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const t = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const handledInitialAdd = useRef(false);
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
};
|
||||
useEffect(() => {
|
||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
||||
handledInitialAdd.current = true;
|
||||
addWidget();
|
||||
|
||||
const handleSelectApp = () => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddIntegration = () => {
|
||||
openIntegrationSelectModal({});
|
||||
};
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("add");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [addWidget, board.id, pathname, router, searchParams]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||
{t("section.container.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<HeaderButton
|
||||
aria-label={t("board.action.addContent")}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
boardActions: {
|
||||
widget: addWidget,
|
||||
app: addApp,
|
||||
integration: canConnectService ? connectService : undefined,
|
||||
container: addContainer,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLayoutBoard,
|
||||
@@ -98,6 +99,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
|
||||
latestBoardRef.current = board;
|
||||
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
||||
onMutate(boardToSave) {
|
||||
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("notification.success.title"),
|
||||
|
||||
@@ -54,7 +54,12 @@ export default async function ManageBoardsPage() {
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && (
|
||||
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||
<NoResults
|
||||
icon={IconLayoutDashboard}
|
||||
title={t("noResults.title")}
|
||||
description={t("noResults.description")}
|
||||
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
|
||||
/>
|
||||
)}
|
||||
{boards.length > 0 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
@@ -21,9 +23,10 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
|
||||
import { downloadJson } from "~/components/custom-widgets/download";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -40,12 +43,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [publishOpened, publishControls] = useDisclosure(false);
|
||||
const [migrationOpened, migrationControls] = useDisclosure(false);
|
||||
const migrationFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
|
||||
const copyMigrationPrompt = async () => {
|
||||
try {
|
||||
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
|
||||
@@ -62,30 +64,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const copyLegacyDefinition = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
|
||||
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
||||
showSuccessNotification({
|
||||
title: t("action.copyLegacyDefinition"),
|
||||
message: t("notification.legacyDefinitionCopied"),
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("action.copyLegacyDefinition"),
|
||||
message: error instanceof Error ? error.message : t("notification.legacyDefinitionCopyError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const queueMigratedWidget = (text: string) => {
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
if (!result.success) {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: formatCustomWidgetImportIssues(result.issues),
|
||||
});
|
||||
showErrorNotification({ title: t("action.migrate"), message: formatCustomWidgetImportIssues(result.issues) });
|
||||
return;
|
||||
}
|
||||
setMigratedWidget(result.widget);
|
||||
migrationControls.open();
|
||||
};
|
||||
const notifyMigrationError = () =>
|
||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||
|
||||
const pasteMigratedWidget = async () => {
|
||||
try {
|
||||
queueMigratedWidget(await navigator.clipboard.readText());
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
notifyMigrationError();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -94,15 +107,10 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
try {
|
||||
queueMigratedWidget(loadEvent.target?.result as string);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("action.migrate"),
|
||||
message: t("notification.migrationError"),
|
||||
});
|
||||
notifyMigrationError();
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => {
|
||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||
});
|
||||
reader.addEventListener("error", notifyMigrationError);
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
@@ -110,18 +118,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${widget.name}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
downloadJson(data, `${widget.name}.json`);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.export"), message: t("notification.exportError") });
|
||||
}
|
||||
};
|
||||
|
||||
const exportLegacyDefinition = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
|
||||
downloadJson(data, `${widget.name}.legacy.json`);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.exportLegacyDefinition"), message: t("notification.exportError") });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.widget.customApi.getData.cancel();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
duplicateMutation.mutate(
|
||||
{ id: widget.id },
|
||||
@@ -146,22 +177,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
title: t("action.delete"),
|
||||
children: t("action.deleteConfirm", { name: widget.name }),
|
||||
onConfirm: () => {
|
||||
const callbacks = {
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
deleteMutation.mutate(
|
||||
{ id: widget.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
};
|
||||
deleteMutation.mutate({ id: widget.id }, callbacks);
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -177,6 +210,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Dropdown>
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Menu.Item
|
||||
onClick={handleToggleEnabled}
|
||||
leftSection={widget.enabled ? <IconPlayerPause {...iconProps} /> : <IconPlayerPlay {...iconProps} />}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
{widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
@@ -187,7 +228,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/publish/${widget.id}`}
|
||||
leftSection={<IconBuildingStore {...iconProps} />}
|
||||
>
|
||||
{t("action.publishWorkshop")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
@@ -195,6 +240,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
)}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<Menu.Item onClick={() => void copyLegacyDefinition()} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("action.copyLegacyDefinition")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void exportLegacyDefinition()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.exportLegacyDefinition")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
|
||||
{t("action.copyMigrationPrompt")}
|
||||
</Menu.Item>
|
||||
@@ -222,7 +274,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<input
|
||||
|
||||
+11
-5
@@ -19,12 +19,18 @@ describe("legacy custom widget migration UI", () => {
|
||||
});
|
||||
|
||||
it("replaces the legacy record through migration and refreshes every consumer", () => {
|
||||
// The import pipeline is shared by the dialog and the Workshop install page.
|
||||
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
|
||||
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
|
||||
|
||||
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
|
||||
expect(dialog).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(dialog).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(importer).toMatch(
|
||||
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
|
||||
);
|
||||
expect(importer).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(importer).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
expect(dialog).toContain("useCustomWidgetImport({");
|
||||
expect(dialog).toContain("legacyId,");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
|
||||
@@ -31,161 +29,69 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{definitions.map((def) => (
|
||||
<CustomWidgetCard key={def.id} widget={def} />
|
||||
<ManageCollectionList ariaLabel={t("page.list.title")}>
|
||||
{definitions.map((definition) => (
|
||||
<CustomWidgetRow key={definition.id} widget={definition} />
|
||||
))}
|
||||
</Stack>
|
||||
</ManageCollectionList>
|
||||
);
|
||||
};
|
||||
|
||||
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.widget.customApi.getData.cancel();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
const origins = [...new Set(widget.sources.map((source) => source.origin))];
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
withBorder={!widget.valid}
|
||||
bd={
|
||||
widget.migrationRequired
|
||||
? "1px solid var(--mantine-color-yellow-6)"
|
||||
: !widget.valid
|
||||
? "1px solid var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar
|
||||
size={40}
|
||||
radius="sm"
|
||||
src={widget.iconUrl}
|
||||
color={widget.valid ? "pink" : "red"}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
alt=""
|
||||
>
|
||||
<IconApi size={20} stroke={1.5} />
|
||||
</Avatar>
|
||||
}
|
||||
style={{
|
||||
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
{widget.iconUrl ? (
|
||||
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<Avatar size={36} radius="sm" color="red">
|
||||
<IconApi size={18} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
}
|
||||
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>
|
||||
{origins.map((origin) => (
|
||||
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
|
||||
· {origin}
|
||||
</Text>
|
||||
{widget.migrationRequired ? (
|
||||
<Text size="xs" c="yellow.8">
|
||||
{t("page.list.migrationDescription")}
|
||||
</Text>
|
||||
) : !widget.valid ? (
|
||||
<Text size="xs" c="red">
|
||||
{t("page.list.invalidDefinitionDescription")}
|
||||
</Text>
|
||||
) : null}
|
||||
{widget.sources[0] && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{widget.migrationRequired ? (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : widget.valid ? (
|
||||
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||
JSX · {widget.requestCount}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={widget.validationIssues
|
||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||
.join("\n")}
|
||||
multiline
|
||||
maw={520}
|
||||
>
|
||||
<Badge
|
||||
color="red"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.invalidDefinition")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{widget.missingSecrets.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
}
|
||||
actions={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={widget.enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("action.edit")}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
@@ -197,7 +103,65 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything that decides whether this widget can actually run right now. */
|
||||
function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
if (widget.migrationRequired) {
|
||||
return (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge color="yellow" size="sm" variant="light" leftSection={<IconSparkles size={12} />}>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.valid) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={widget.validationIssues
|
||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||
.join("\n")}
|
||||
multiline
|
||||
maw={520}
|
||||
>
|
||||
<Badge color="red" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
|
||||
{t("page.list.invalidDefinition")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{widget.enabled ? (
|
||||
<Badge color="green" size="sm" variant="light">
|
||||
{t("page.list.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label={t("page.list.disabledDescription")}>
|
||||
<Badge color="gray" size="sm" variant="light">
|
||||
{t("page.list.disabled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{widget.missingSecrets.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<Badge color="yellow" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
const tabs = {
|
||||
installed: "/manage/custom-widgets",
|
||||
workshop: "/manage/custom-widgets/workshop",
|
||||
} as const;
|
||||
|
||||
/** Switches between the widgets you have installed and the community Workshop. */
|
||||
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
|
||||
const t = useScopedI18n("customWidget.page.tabs");
|
||||
|
||||
return (
|
||||
<Tabs value={tabs[active]}>
|
||||
<Tabs.List aria-label={t("ariaLabel")}>
|
||||
<Tabs.Tab
|
||||
value={tabs.installed}
|
||||
leftSection={<IconApi size={16} stroke={1.5} />}
|
||||
renderRoot={(props) => <Link href={tabs.installed} {...props} />}
|
||||
>
|
||||
{t("installed")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value={tabs.workshop}
|
||||
leftSection={<IconBuildingStore size={16} stroke={1.5} />}
|
||||
renderRoot={(props) => <Link href={tabs.workshop} {...props} />}
|
||||
>
|
||||
{t("workshop")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -10,9 +10,9 @@ import { Link } from "@homarr/ui";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
import { CustomWidgetTabs } from "./_custom-widget-tabs";
|
||||
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
|
||||
|
||||
export default async function CustomWidgetsPage() {
|
||||
const session = await auth();
|
||||
@@ -38,13 +38,13 @@ export default async function CustomWidgetsPage() {
|
||||
>
|
||||
{t("action.downloadSkill")}
|
||||
</MobileAffixButton>
|
||||
<WorkshopInstallButton />
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
</MobileAffixButton>
|
||||
</Group>
|
||||
}
|
||||
toolbar={<CustomWidgetTabs active="installed" />}
|
||||
>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetList definitions={definitions} />
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
FileInput,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconBuildingStore,
|
||||
IconCheck,
|
||||
IconExternalLink,
|
||||
IconPhoto,
|
||||
IconShieldCheck,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
|
||||
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
|
||||
import {
|
||||
getPrivateWorkshopSourceNames,
|
||||
publishWorkshopDefinition,
|
||||
serializeWorkshopDefinition,
|
||||
} from "~/components/workshop/workshop-publish-definition";
|
||||
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
|
||||
|
||||
const listHref = "/manage/custom-widgets";
|
||||
|
||||
export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const session = useWorkshopSession();
|
||||
const [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [publishedSubmissionId, setPublishedSubmissionId] = useState<string | null>(null);
|
||||
|
||||
const createSubmission = useWorkshopCreateMutation(session.client);
|
||||
const definition = clientApi.customWidget.export.useQuery({ id: widget.id });
|
||||
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
|
||||
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
|
||||
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
|
||||
|
||||
const breadcrumb = <DynamicBreadcrumb dynamicMappings={new Map([[widget.id, widget.name]])} />;
|
||||
|
||||
const publish = async () => {
|
||||
// Publishing is immediate and public; a second in-flight call would create a
|
||||
// duplicate listing.
|
||||
if (createSubmission.isPending) return;
|
||||
setError(null);
|
||||
try {
|
||||
if (!definition.data) throw new Error(t("publish.error"));
|
||||
if (!workshopScreenshotsSchema.safeParse(screenshots).success) throw new Error(t("publish.invalidScreenshot"));
|
||||
|
||||
let submissionId: string | null = null;
|
||||
const result = await publishWorkshopDefinition({
|
||||
inspectedDefinition: definition.data,
|
||||
refetchDefinition: async () => (await definition.refetch()).data,
|
||||
publish: async (content) => {
|
||||
const submission = await createSubmission.mutateAsync({
|
||||
input: { type: "customWidget", title, description, content },
|
||||
screenshots,
|
||||
});
|
||||
submissionId = submission.id;
|
||||
},
|
||||
});
|
||||
if (result === "unavailable") throw new Error(t("publish.error"));
|
||||
if (result === "changed") {
|
||||
setSourceUrlsReviewed(false);
|
||||
setError(t("publish.definitionChanged"));
|
||||
return;
|
||||
}
|
||||
if (!submissionId) throw new Error(t("publish.error"));
|
||||
setPublishedSubmissionId(submissionId);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("publish.error"));
|
||||
}
|
||||
};
|
||||
|
||||
if (publishedSubmissionId) {
|
||||
return (
|
||||
<ManagePageLayout title={t("publish.successTitle")} breadcrumb={breadcrumb}>
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Stack align="center" gap="lg">
|
||||
<ThemeIcon size={64} radius="xl" color="green" variant="light">
|
||||
<IconCheck size={32} stroke={2} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed" ta="center" maw={520}>
|
||||
{t("publish.success")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" ta="center" maw={520}>
|
||||
{t("publish.manageDescription")}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button component={Link} href={listHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
|
||||
{t("publish.done")}
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={getWorkshopWebUrl(publishedSubmissionId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rightSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t("publish.viewSubmission")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const blocked =
|
||||
!session.user ||
|
||||
createSubmission.isPending ||
|
||||
!definition.data ||
|
||||
definition.isError ||
|
||||
title.trim().length < 3 ||
|
||||
(privateSourceNames.length > 0 && !sourceUrlsReviewed);
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Title order={1}>{t("publish.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{widget.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
breadcrumb={breadcrumb}
|
||||
primaryAction={<WorkshopAccountButton session={session} />}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-default-hover)">
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon variant="light" radius="md" size="lg">
|
||||
<IconBuildingStore size={20} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600}>{t("publish.introTitle")}</Text>
|
||||
<Text c="dimmed" size="sm" mt={2}>
|
||||
{t("publish.introDescription")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{!session.user && <Alert color="blue">{t("publish.signInHint")}</Alert>}
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600} size="sm">
|
||||
{t("publish.listingDetails")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" mt={2}>
|
||||
{t("publish.listingDetailsDescription")}
|
||||
</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("publish.titleField")}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.descriptionField")}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
minRows={4}
|
||||
autosize
|
||||
maxRows={8}
|
||||
/>
|
||||
<FileInput
|
||||
label={t("publish.screenshots")}
|
||||
description={t("publish.screenshotsDescription")}
|
||||
placeholder={t("publish.screenshotsPlaceholder")}
|
||||
leftSection={<IconPhoto size={16} />}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
value={screenshots}
|
||||
onChange={setScreenshots}
|
||||
clearable
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{privateSourceNames.length > 0 && (
|
||||
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
|
||||
{t("publish.privateSourceWarning", {
|
||||
sources: privateSourceNames.join(", "),
|
||||
})}
|
||||
<Checkbox
|
||||
mt="sm"
|
||||
checked={sourceUrlsReviewed}
|
||||
onChange={(event) => setSourceUrlsReviewed(event.currentTarget.checked)}
|
||||
label={t("publish.privateSourceConfirmation")}
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
|
||||
<ManageStickyFooter
|
||||
secondary={
|
||||
<Text c="dimmed" size="xs" maw={420}>
|
||||
{t("publish.publicationNote")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Button component={Link} href={listHref} variant="default">
|
||||
{t("publish.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
loading={createSubmission.isPending || definition.isFetching}
|
||||
disabled={blocked}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{t("publish.action")}
|
||||
</Button>
|
||||
</ManageStickyFooter>
|
||||
</Stack>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { WorkshopPublishForm } from "./_workshop-publish-form";
|
||||
|
||||
interface WorkshopPublishPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function WorkshopPublishPage(props: WorkshopPublishPageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
|
||||
const { id } = await props.params;
|
||||
const definition = await api.customWidget.get({ id }).catch(catchTrpcNotFound);
|
||||
|
||||
return <WorkshopPublishForm widget={{ id, name: definition.name }} />;
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconFlag } from "@tabler/icons-react";
|
||||
|
||||
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
|
||||
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
|
||||
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop/schema";
|
||||
|
||||
import { useCustomWidgetImport } from "~/components/custom-widgets/use-custom-widget-import";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
|
||||
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
|
||||
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
|
||||
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
|
||||
import { useWorkshopSession } from "~/components/workshop/workshop-session";
|
||||
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
|
||||
|
||||
const browseHref = "/manage/custom-widgets/workshop";
|
||||
const installedHref = "/manage/custom-widgets";
|
||||
|
||||
export function WorkshopDetail({ id }: { id: string }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const router = useRouter();
|
||||
const session = useWorkshopSession();
|
||||
const [reportOpened, reportControls] = useDisclosure(false);
|
||||
|
||||
const detail = useWorkshopSubmissionQuery(session.client, id);
|
||||
const reportSummaries = useWorkshopReportSummariesQuery(session.client, detail.data?.reportCount ? id : "");
|
||||
|
||||
const content = detail.data?.content;
|
||||
const validation = useMemo(() => (content ? validateWorkshopWidget(content) : null), [content]);
|
||||
const widget = useMemo<HomarrCustomWidgetV2 | null>(
|
||||
() => (validation?.success && typeof validation.data !== "string" ? validation.data : null),
|
||||
[validation],
|
||||
);
|
||||
const compatible =
|
||||
detail.data?.type === "customWidget" && widget !== null && detail.data.widgetSchema === CUSTOM_WIDGET_SCHEMA;
|
||||
|
||||
const importer = useCustomWidgetImport({
|
||||
widget: compatible ? widget : null,
|
||||
// Straight back to the installed list: the edit form is heavy to load and the
|
||||
// widget is usable as-is once its sources are configured here.
|
||||
onImported: () => router.push(installedHref),
|
||||
});
|
||||
|
||||
const breadcrumb = (
|
||||
<DynamicBreadcrumb dynamicMappings={detail.data ? new Map([[id, detail.data.title]]) : undefined} />
|
||||
);
|
||||
|
||||
if (detail.isPending) {
|
||||
return (
|
||||
<ManagePageLayout title={<Skeleton h={36} w={280} />} breadcrumb={breadcrumb}>
|
||||
<Skeleton h={420} radius="md" />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.isError || !detail.data) {
|
||||
return (
|
||||
<ManagePageLayout title={t("title")} breadcrumb={breadcrumb}>
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("loadError")}>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">{detail.error instanceof Error ? detail.error.message : t("unavailable")}</Text>
|
||||
<Button
|
||||
component={Link}
|
||||
href={browseHref}
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconArrowLeft size={14} />}
|
||||
>
|
||||
{t("back")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const submission = detail.data;
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Title order={1}>{submission.title}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("author", {
|
||||
name: submission.authorName || t("communityMember"),
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
breadcrumb={breadcrumb}
|
||||
primaryAction={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button component={Link} href={browseHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={getWorkshopWebUrl(submission.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{submission.outdated && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("outdatedWarning")}
|
||||
</Alert>
|
||||
)}
|
||||
{submission.reportCount > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("reportWarning", { count: submission.reportCount })}
|
||||
</Alert>
|
||||
{reportSummaries.data?.map((summary) => (
|
||||
<Card key={summary.id} withBorder radius="md" p="sm">
|
||||
<Badge color="red" variant="light" mb="xs">
|
||||
{t(`reportCategory.${summary.category}`)}
|
||||
</Badge>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{summary.explanation}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
{reportSummaries.data?.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("reportVisibility")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{!compatible && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("installError")}>
|
||||
{t("installErrorDescription")}
|
||||
{validation && !validation.success ? ` ${validation.error}` : ""}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{submission.description && <Text>{submission.description}</Text>}
|
||||
|
||||
<WorkshopScreenshots
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
title={submission.title}
|
||||
screenshots={submission.screenshots}
|
||||
/>
|
||||
|
||||
{compatible && importer.review && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600}>{t("installReviewTitle")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("securityNotice")}
|
||||
</Text>
|
||||
</Box>
|
||||
<ImportReviewContent review={importer.review} messages={importer.reviewMessages}>
|
||||
<CustomWidgetSourceSetupPanel
|
||||
setups={importer.setups}
|
||||
values={importer.values}
|
||||
onChange={importer.setValue}
|
||||
messages={importer.setupMessages}
|
||||
/>
|
||||
</ImportReviewContent>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="technical-details">
|
||||
<Accordion.Control>{t("technicalDetails")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<WorkshopCodeViewer
|
||||
value={submission.content}
|
||||
language={submission.type === "customCss" ? "css" : "json"}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<ManageStickyFooter
|
||||
secondary={
|
||||
<>
|
||||
<WorkshopVoteControl
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
score={submission.score}
|
||||
canVote={session.user !== null}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() => downloadSubmission(submission)}
|
||||
>
|
||||
{t("export")}
|
||||
</Button>
|
||||
{/* Wrapped: a tooltip on the disabled button itself would never fire. */}
|
||||
<Tooltip label={session.user ? t("report") : t("signInHint")}>
|
||||
<span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
leftSection={<IconFlag size={16} />}
|
||||
disabled={!session.user}
|
||||
onClick={reportControls.open}
|
||||
>
|
||||
{t("report")}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="md"
|
||||
loading={importer.pending || importer.succeeded}
|
||||
disabled={!compatible || !importer.ready || importer.succeeded}
|
||||
onClick={importer.importWidget}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
</ManageStickyFooter>
|
||||
</Stack>
|
||||
|
||||
<WorkshopReportModal
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
opened={reportOpened}
|
||||
onClose={reportControls.close}
|
||||
/>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadSubmission(submission: WorkshopSubmissionDetail) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([submission.content], {
|
||||
type: submission.type === "customCss" ? "text/css" : "application/json",
|
||||
}),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = workshopExportFilename(submission.title, submission.type);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { WorkshopDetail } from "./_workshop-detail";
|
||||
|
||||
interface WorkshopDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function WorkshopDetailPage(props: WorkshopDetailPageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
|
||||
const { id } = await props.params;
|
||||
|
||||
return <WorkshopDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { ActionIcon, Button, Group, Select, Stack, Tooltip } from "@mantine/core";
|
||||
import { IconBuildingStore, IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
import { useWorkshopQuery } from "@homarr/workshop/backend";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
|
||||
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
|
||||
import { WorkshopSubmissionGrid } from "~/components/workshop/workshop-submission-grid";
|
||||
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
|
||||
import { CustomWidgetTabs } from "../_custom-widget-tabs";
|
||||
|
||||
export const WORKSHOP_PAGE_SIZE = 12;
|
||||
|
||||
interface WorkshopBrowseProps {
|
||||
search: string | undefined;
|
||||
sort: "top" | "newest";
|
||||
page: number;
|
||||
}
|
||||
|
||||
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const session = useWorkshopSession();
|
||||
const query = useWorkshopQuery(session.client, {
|
||||
page,
|
||||
perPage: WORKSHOP_PAGE_SIZE,
|
||||
type: "customWidget",
|
||||
sort,
|
||||
search,
|
||||
});
|
||||
const totalPages = query.data?.totalPages ?? 1;
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<WorkshopAccountButton session={session} />
|
||||
<Button
|
||||
component="a"
|
||||
href={getWorkshopWebUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
toolbar={
|
||||
<Stack gap="sm">
|
||||
<CustomWidgetTabs active="workshop" />
|
||||
<Group gap="sm" align="flex-end" wrap="nowrap">
|
||||
<SearchInput placeholder={`${t("search")}...`} ariaLabel={t("search")} defaultValue={search} flexExpand />
|
||||
<WorkshopSortSelect value={sort} />
|
||||
</Group>
|
||||
</Stack>
|
||||
}
|
||||
footer={totalPages > 1 ? <TablePagination total={totalPages} /> : undefined}
|
||||
>
|
||||
<WorkshopSubmissionGrid
|
||||
client={session.client}
|
||||
query={query}
|
||||
ariaLabel={t("listAriaLabel")}
|
||||
emptyState={
|
||||
search ? (
|
||||
<NoResults
|
||||
icon={IconBuildingStore}
|
||||
title={t("emptyFilteredTitle")}
|
||||
description={t("emptyFilteredDescription", { search })}
|
||||
action={{
|
||||
label: t("clearSearch"),
|
||||
href: "/manage/custom-widgets/workshop",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<NoResults icon={IconBuildingStore} title={t("empty")} description={t("emptyDescription")} />
|
||||
)
|
||||
}
|
||||
renderActions={(item) => (
|
||||
<>
|
||||
<WorkshopVoteControl
|
||||
client={session.client}
|
||||
submissionId={item.id}
|
||||
score={item.score}
|
||||
canVote={session.user !== null}
|
||||
size="sm"
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label={t("openCommunity")}>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={getWorkshopWebUrl(item.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
aria-label={t("openOnWorkshop", { title: item.title })}
|
||||
>
|
||||
<IconExternalLink size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button component={Link} href={`/manage/custom-widgets/workshop/${item.id}`} size="sm">
|
||||
{t("install")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopSortSelect({ value }: { value: "top" | "newest" }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string | null) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (next === "newest") params.set("sort", next);
|
||||
else params.delete("sort");
|
||||
params.delete("page");
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname);
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label={t("sort")}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
allowDeselect={false}
|
||||
w={160}
|
||||
data={[
|
||||
{ value: "top", label: t("sortTop") },
|
||||
{ value: "newest", label: t("sortNewest") },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
|
||||
import { WorkshopBrowse } from "./_workshop-browse";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
sort: z.enum(["top", "newest"]).catch("top"),
|
||||
page: z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u)
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.catch(1),
|
||||
});
|
||||
|
||||
interface WorkshopBrowsePageProps {
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function WorkshopBrowsePage(props: WorkshopBrowsePageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
|
||||
const { search, sort, page } = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
return <WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} />;
|
||||
}
|
||||
+11
-3
@@ -42,7 +42,11 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
<Text size="md">
|
||||
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
{error.data.url}
|
||||
</Anchor>
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -50,7 +54,11 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
{t.rich("integration.testConnection.error.statusCode.description", {
|
||||
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
|
||||
statusCode: error.data.statusCode.toString(),
|
||||
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
|
||||
url: () => (
|
||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
||||
{error.data.url}
|
||||
</Anchor>
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
@@ -66,7 +74,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
{t("integration.testConnection.error.common.cause.title")}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<pre style={{ whiteSpace: "pre-wrap" }}>
|
||||
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||||
{error.name}: {error.message}
|
||||
{"\n"}
|
||||
{causeArray
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
export type AnyMappedTestConnectionError = Exclude<RouterOutputs["integration"]["create"], undefined>["error"];
|
||||
type IntegrationCreateErrorResult = Extract<RouterOutputs["integration"]["create"], { error: unknown }>;
|
||||
|
||||
export type AnyMappedTestConnectionError = IntegrationCreateErrorResult["error"];
|
||||
export type MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
|
||||
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
|
||||
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;
|
||||
+68
-48
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
||||
@@ -25,8 +25,10 @@ import { SecretKindsSegmentedControl } from "../../_components/secrets/integrati
|
||||
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
|
||||
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
|
||||
|
||||
interface EditIntegrationForm {
|
||||
interface EditIntegrationFormProps {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
embedded?: boolean;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
@@ -42,7 +44,7 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
}),
|
||||
);
|
||||
|
||||
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||
@@ -67,53 +69,65 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
app: integration.app ?? null,
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||
const errorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||
|
||||
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
|
||||
const url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
await mutateAsync(
|
||||
{
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
let url: string;
|
||||
try {
|
||||
url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessNotification({
|
||||
title: t("integration.page.edit.notification.success.title"),
|
||||
message: t("integration.page.edit.notification.success.message"),
|
||||
});
|
||||
void utils.integration.invalidate();
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
const data = await mutateAsync({
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
});
|
||||
|
||||
// We do it this way as we are unable to send a typesafe error through onError
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
title: t("integration.page.edit.notification.error.title"),
|
||||
message: t("integration.page.edit.notification.error.message"),
|
||||
});
|
||||
requestAnimationFrame(() => errorRef.current?.focus());
|
||||
return;
|
||||
}
|
||||
|
||||
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 (!embedded) {
|
||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
}
|
||||
};
|
||||
|
||||
const isInitialSecretKinds =
|
||||
@@ -190,12 +204,18 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
{!embedded && (
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.edit")}
|
||||
</Button>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
@@ -49,10 +50,12 @@ interface NewIntegrationFormProps {
|
||||
kind: IntegrationKind;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
onSuccess: () => void;
|
||||
onSuccess: (result?: CreatedIntegrationResult) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export type CreatedIntegrationResult = Extract<RouterOutputs["integration"]["create"], { integration: unknown }>;
|
||||
|
||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
z.object({
|
||||
hasApp: z.boolean(),
|
||||
@@ -67,12 +70,18 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const { data: session } = useSession();
|
||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||
const validationSchema = canCreateApps
|
||||
? formSchema
|
||||
: formSchema.superRefine((values, context) => {
|
||||
if (!values.hasApp || values.appId !== null) return;
|
||||
context.addIssue({ code: "custom", message: t("common.zod.errors.required"), path: ["appId"] });
|
||||
});
|
||||
|
||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
||||
if (hasUrlSecret) {
|
||||
url = "http://localhost";
|
||||
}
|
||||
const form = useZodForm(formSchema, {
|
||||
const form = useZodForm(validationSchema, {
|
||||
initialValues: {
|
||||
name: initialName ?? getIntegrationName(kind),
|
||||
url,
|
||||
@@ -81,7 +90,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: true,
|
||||
hasApp: true,
|
||||
hasApp: canCreateApps,
|
||||
appHref: url,
|
||||
appId: null,
|
||||
},
|
||||
@@ -101,7 +110,9 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
|
||||
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
|
||||
const onMutationSuccess = (
|
||||
data: CreatedIntegrationResult | { error?: AnyMappedTestConnectionError } | undefined | void,
|
||||
) => {
|
||||
if (data && "error" in data && data.error) {
|
||||
setError(data.error);
|
||||
showErrorNotification({
|
||||
@@ -116,7 +127,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
onSuccess(data && "integration" in data ? data : undefined);
|
||||
};
|
||||
|
||||
const onMutationError = () => {
|
||||
@@ -131,13 +142,15 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
||||
const app = hasApp
|
||||
? appId !== null
|
||||
? { id: appId }
|
||||
: {
|
||||
name: values.name,
|
||||
href: hasCustomHref ? appHref : url,
|
||||
iconUrl: getIconUrl(kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
}
|
||||
: canCreateApps
|
||||
? {
|
||||
name: values.name,
|
||||
href: hasCustomHref ? appHref : url,
|
||||
iconUrl: getIconUrl(kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
}
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
await createIntegrationAsync(
|
||||
@@ -229,8 +242,10 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
||||
checkboxInputProps.onChange(event);
|
||||
});
|
||||
}}
|
||||
label={t("integration.field.createApp.label")}
|
||||
description={t("integration.field.createApp.description")}
|
||||
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
|
||||
description={t(
|
||||
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Collapse expanded={form.values.hasApp}>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
IconApi,
|
||||
IconBook2,
|
||||
IconBox,
|
||||
IconBuildingStore,
|
||||
IconBrandDiscord,
|
||||
IconBrandDocker,
|
||||
IconBrandGithub,
|
||||
@@ -102,12 +101,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconBuildingStore,
|
||||
href: "/manage/workshop",
|
||||
label: t("items.workshop"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getValidDockerServiceUrl,
|
||||
} 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("accepts only HTTP service URLs", () => {
|
||||
expect(getValidDockerServiceUrl("https://service.example.com")).toBe("https://service.example.com/");
|
||||
expect(getValidDockerServiceUrl("ftp://service.example.com")).toBeNull();
|
||||
expect(getValidDockerServiceUrl("service.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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];
|
||||
|
||||
export const getValidDockerServiceUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconArrowRight, IconEyeOff, IconPlugConnected, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
|
||||
import {
|
||||
dismissDockerReconciliationCandidate,
|
||||
filterDockerReconciliationInbox,
|
||||
getValidDockerServiceUrl,
|
||||
} from "./docker-reconciliation-inbox";
|
||||
|
||||
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
|
||||
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
|
||||
|
||||
export const DockerReconciliation = () => {
|
||||
const t = useScopedI18n("docker.reconciliation");
|
||||
const utils = clientApi.useUtils();
|
||||
const reconciliation = clientApi.docker.reconcileServices.useQuery();
|
||||
const health = clientApi.docker.getServiceHealth.useQuery();
|
||||
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: [],
|
||||
});
|
||||
|
||||
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()}>
|
||||
{t("action.retry")}
|
||||
</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 = health.data
|
||||
? health.data.services.filter(
|
||||
({ key, status }) =>
|
||||
["newRecognized", "newApp", "moved"].includes(status) && !dismissedCandidateKeys.includes(key),
|
||||
).length
|
||||
: 0;
|
||||
const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching;
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="start">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<IconPlugConnected size={20} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("description")}
|
||||
</Text>
|
||||
</div>
|
||||
{health.data && <Badge variant="light">{t("attention", { count: String(attentionCount) })}</Badge>}
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<SegmentedControl
|
||||
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="xs" onClick={() => setDismissedCandidateKeys([])}>
|
||||
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
loading={isRefreshing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
>
|
||||
{t("action.refresh")}
|
||||
</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>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }}>
|
||||
{candidates.map((candidate) => (
|
||||
<DockerReconciliationCandidate
|
||||
key={candidate.candidateKey}
|
||||
candidate={candidate}
|
||||
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
|
||||
onDismiss={() =>
|
||||
setDismissedCandidateKeys((current) =>
|
||||
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const DockerReconciliationCandidate = ({
|
||||
candidate,
|
||||
health,
|
||||
onDismiss,
|
||||
}: {
|
||||
candidate: ReconciliationCandidate;
|
||||
health: ServiceHealth | undefined;
|
||||
onDismiss: () => void;
|
||||
}) => {
|
||||
const t = useScopedI18n("docker.reconciliation");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const initialUrlCandidate =
|
||||
candidate.urlCandidates.find(
|
||||
({ url, scopes }) => url.length > 0 && (candidate.match ? true : scopes.includes("browser")),
|
||||
) ?? candidate.urlCandidates.find(({ source }) => source === "manual");
|
||||
const [selectedCandidateId, setSelectedCandidateId] = useState(initialUrlCandidate?.id ?? null);
|
||||
const [url, setUrl] = useState(initialUrlCandidate?.url ?? "");
|
||||
const selectedCandidate = candidate.urlCandidates.find(({ id }) => id === selectedCandidateId);
|
||||
const validatedUrl = getValidDockerServiceUrl(url);
|
||||
const isInvalidUrl = url.length > 0 && validatedUrl === null;
|
||||
const target = getCandidateTarget(candidate, validatedUrl ?? "");
|
||||
const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
|
||||
const isActionDisabled = actionNeedsUrl && validatedUrl === null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCandidateId(initialUrlCandidate?.id ?? null);
|
||||
setUrl(initialUrlCandidate?.url ?? "");
|
||||
}, [initialUrlCandidate?.id, initialUrlCandidate?.url]);
|
||||
|
||||
return (
|
||||
<Card withBorder padding="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="start" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={candidate.container.iconUrl} radius="sm">
|
||||
{candidate.container.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{candidate.container.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{candidate.endpointName}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={stateColor(candidate.state)} variant="light">
|
||||
{t(`state.${candidate.state}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{candidate.match && (
|
||||
<Group gap="xs">
|
||||
<Badge variant="outline">{candidate.match.kind}</Badge>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("match", { confidence: t(`confidence.${candidate.match.confidence}`) })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{candidate.representation.signals.ambiguous && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("ambiguous")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{health && (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs" mb={4}>
|
||||
{t("health.title")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{health.layers.map((layer) => (
|
||||
<Badge key={layer.layer} color={healthStatusColor(layer.status)} variant="dot" size="sm">
|
||||
{t(`health.layer.${layer.layer}`)}: {t(`health.status.${layer.status}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={t("url.label")}
|
||||
description={selectedCandidate ? t(`reason.${selectedCandidate.reason}`) : t("reason.manualHostRequired")}
|
||||
value={selectedCandidateId}
|
||||
data={candidate.urlCandidates.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.url || t("url.manual"),
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSelectedCandidateId(value);
|
||||
setUrl(candidate.urlCandidates.find(({ id }) => id === value)?.url ?? "");
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("url.inputLabel")}
|
||||
aria-label={t("url.inputLabel")}
|
||||
value={url}
|
||||
placeholder="https://service.example.com"
|
||||
error={isInvalidUrl ? t("url.invalid") : undefined}
|
||||
onChange={(event) => setUrl(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" color="gray" leftSection={<IconEyeOff size={16} />} onClick={onDismiss}>
|
||||
{t("action.dismiss")}
|
||||
</Button>
|
||||
{target.kind === "createApp" ? (
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
selectedContainers: [candidate.container],
|
||||
initialUrls: validatedUrl ? [validatedUrl] : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("action.createApp")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
component={Link}
|
||||
href={target.href}
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
disabled={isActionDisabled}
|
||||
onClick={(event) => {
|
||||
if (isActionDisabled) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t(`action.${target.kind}`)}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const getCandidateTarget = (candidate: ReconciliationCandidate, url: string) => {
|
||||
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) {
|
||||
const params = new URLSearchParams({ kind: candidate.match.kind, name: candidate.container.name });
|
||||
if (url) params.set("url", url);
|
||||
return { kind: "setupIntegration" as const, href: `/manage/integrations/new?${params.toString()}` };
|
||||
}
|
||||
|
||||
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 healthStatusColor = (status: ServiceHealth["layers"][number]["status"]) => {
|
||||
if (["available", "configured", "linked"].includes(status)) return "green";
|
||||
if (["missing", "changed", "unused"].includes(status)) return "yellow";
|
||||
return "gray";
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Alert, Avatar, Badge, Box, Button, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCategoryPlus,
|
||||
IconDots,
|
||||
IconFileText,
|
||||
@@ -20,10 +20,10 @@ import { MantineReactTable } from "mantine-react-table";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import type { ContainerState, DockerEndpointCapability } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -32,9 +32,41 @@ import { OverflowBadge } from "@homarr/ui";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
type DockerEndpoint = RouterOutputs["docker"]["getContainers"]["endpoints"][number];
|
||||
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
|
||||
|
||||
const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id });
|
||||
|
||||
const showContainerActionResult = (
|
||||
action: "start" | "stop" | "restart" | "remove",
|
||||
containers: DockerContainer[],
|
||||
results: RouterOutputs["docker"]["startAll"],
|
||||
t: TranslationFunction,
|
||||
) => {
|
||||
const failures = results.filter((result) => !result.success);
|
||||
if (failures.length === 0) {
|
||||
showSuccessNotification({
|
||||
title: t(`docker.action.${action}.notification.success.title`),
|
||||
message: t(`docker.action.${action}.notification.success.message`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const failedNames = failures.map(
|
||||
({ target }) =>
|
||||
containers.find(({ endpointId, id }) => endpointId === target.endpointId && id === target.id)?.name ?? target.id,
|
||||
);
|
||||
showErrorNotification({
|
||||
title: t("docker.action.result.failure.title"),
|
||||
message: t("docker.action.result.failure.message", {
|
||||
failed: String(failures.length),
|
||||
total: String(results.length),
|
||||
names: failedNames.join(", "),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
||||
{
|
||||
@@ -157,32 +189,39 @@ interface DockerTableProps {
|
||||
export function DockerTable({ initialData }: DockerTableProps) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
utils.docker.getContainers.invalidate(),
|
||||
utils.docker.reconcileServices.invalidate(),
|
||||
utils.docker.getServiceHealth.invalidate(),
|
||||
]);
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||
const mutate = useCallback(() => {
|
||||
void refetch().then((result) => {
|
||||
if (result.isError) {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
} else {
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [refetch, tDocker]);
|
||||
|
||||
const containers = data?.containers ?? [];
|
||||
const unavailableEndpoints = data?.endpoints.filter(({ status }) => status === "unavailable") ?? [];
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: containers,
|
||||
state: { isLoading: isFetching },
|
||||
getRowId: (row) => row.resourceId,
|
||||
state: { isLoading: isFetching || refreshInventory.isPending },
|
||||
enableDensityToggle: false,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
@@ -207,13 +246,18 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
|
||||
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
|
||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => (
|
||||
<ContainerRowMenu
|
||||
container={row.original}
|
||||
endpoint={data?.endpoints.find(({ id }) => id === row.original.endpointId)}
|
||||
/>
|
||||
),
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button
|
||||
variant="default"
|
||||
rightSection={<IconRefresh size="1rem" />}
|
||||
onClick={() => mutate()}
|
||||
loading={isFetching}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
loading={isFetching || refreshInventory.isPending}
|
||||
>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
@@ -229,7 +273,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
totalCount: String(table.getRowCount()),
|
||||
})}
|
||||
</Text>
|
||||
<ContainerActionBar selectedContainers={dockerContainers} />
|
||||
<ContainerActionBar selectedContainers={dockerContainers} endpoints={data?.endpoints ?? []} />
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -238,10 +282,18 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
{unavailableEndpoints.length > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{tDocker("error.endpointUnavailable", {
|
||||
count: String(unavailableEndpoints.length),
|
||||
names: unavailableEndpoints.map(({ name }) => name).join(", "),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
|
||||
<MantineReactTable table={table} />
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,22 +304,21 @@ const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon:
|
||||
{ action: "remove", icon: IconTrash, color: "red" },
|
||||
];
|
||||
|
||||
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer; endpoint?: DockerEndpoint }) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const tAll = useI18n();
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const confirmRemoval = useDockerContainerRemovalConfirmation();
|
||||
|
||||
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
|
||||
clientApi.docker[`${action}All`].useMutation({
|
||||
async onSettled() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${action}.notification.success.title`),
|
||||
message: t(`${action}.notification.success.message`),
|
||||
});
|
||||
onSuccess(results) {
|
||||
showContainerActionResult(action, [container], results, tAll);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -294,6 +345,7 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size="1rem" />}
|
||||
disabled={!endpointHasCapability(endpoint, "logs")}
|
||||
onClick={() => router.push(createContainerLogsPath(container))}
|
||||
>
|
||||
{t("logs.label")}
|
||||
@@ -304,7 +356,12 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
key={action}
|
||||
leftSection={<Icon size="1rem" />}
|
||||
color={color}
|
||||
onClick={() => mutations[action].mutate({ ids: [container.id] })}
|
||||
disabled={!endpointHasCapability(endpoint, action === "remove" ? "remove" : "lifecycle")}
|
||||
onClick={() => {
|
||||
const runAction = () => mutations[action].mutate({ targets: [getContainerTarget(container)] });
|
||||
if (action === "remove") confirmRemoval([container], runAction);
|
||||
else runAction();
|
||||
}}
|
||||
>
|
||||
{t(`${action}.label`)}
|
||||
</Menu.Item>
|
||||
@@ -324,18 +381,24 @@ const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
|
||||
interface ContainerActionBarProps {
|
||||
selectedContainers: DockerContainer[];
|
||||
endpoints: DockerEndpoint[];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||
const ContainerActionBar = ({ selectedContainers, endpoints }: ContainerActionBarProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
|
||||
const selectedIds = selectedContainers.map((container) => container.id);
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{containerActions.map(({ action, icon, color }) => (
|
||||
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton
|
||||
key={action}
|
||||
icon={icon}
|
||||
color={color}
|
||||
action={action}
|
||||
selectedContainers={selectedContainers}
|
||||
endpoints={endpoints}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
leftSection={<IconCategoryPlus />}
|
||||
@@ -353,12 +416,22 @@ interface ContainerActionBarButtonProps {
|
||||
icon: TablerIcon;
|
||||
color: MantineColor;
|
||||
action: "start" | "stop" | "restart" | "remove";
|
||||
selectedIds: string[];
|
||||
selectedContainers: DockerContainer[];
|
||||
endpoints: DockerEndpoint[];
|
||||
}
|
||||
|
||||
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const tAll = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const confirmRemoval = useDockerContainerRemovalConfirmation();
|
||||
const requiredCapability = props.action === "remove" ? "remove" : "lifecycle";
|
||||
const isAllowed = props.selectedContainers.every((container) =>
|
||||
endpointHasCapability(
|
||||
props.endpoints.find(({ id }) => id === container.endpointId),
|
||||
requiredCapability,
|
||||
),
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation({
|
||||
async onSettled() {
|
||||
@@ -367,23 +440,28 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
});
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ ids: props.selectedIds },
|
||||
{
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${props.action}.notification.success.title`),
|
||||
message: t(`${props.action}.notification.success.message`),
|
||||
});
|
||||
const runActionAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ targets: props.selectedContainers.map(getContainerTarget) },
|
||||
{
|
||||
onSuccess(results) {
|
||||
showContainerActionResult(props.action, props.selectedContainers, results, tAll);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t(`${props.action}.notification.error.title`),
|
||||
message: t(`${props.action}.notification.error.message`),
|
||||
});
|
||||
},
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t(`${props.action}.notification.error.title`),
|
||||
message: t(`${props.action}.notification.error.message`),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
if (props.action === "remove") {
|
||||
confirmRemoval(props.selectedContainers, runActionAsync);
|
||||
return;
|
||||
}
|
||||
await runActionAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -392,6 +470,7 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
color={props.color}
|
||||
onClick={handleClickAsync}
|
||||
loading={isPending}
|
||||
disabled={!isAllowed}
|
||||
variant="light"
|
||||
>
|
||||
{t(`${props.action}.label`)}
|
||||
@@ -399,6 +478,9 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const endpointHasCapability = (endpoint: DockerEndpoint | undefined, capability: DockerEndpointCapability) =>
|
||||
endpoint && "capabilities" in endpoint ? endpoint.capabilities.includes(capability) : true;
|
||||
|
||||
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||
const t = useScopedI18n("docker.field.state.option");
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ClientSideDockerLogsTerminal } from "./client";
|
||||
|
||||
interface DockerContainerLogsPageProps {
|
||||
params: Promise<{ containerId: string }>;
|
||||
searchParams: Promise<{ name?: string }>;
|
||||
searchParams: Promise<{ endpointId?: string; name?: string }>;
|
||||
}
|
||||
|
||||
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
|
||||
@@ -23,10 +23,12 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
||||
}
|
||||
|
||||
const { containerId } = await params;
|
||||
const { name } = await searchParams;
|
||||
const { endpointId, name } = await searchParams;
|
||||
const resolvedEndpointId = endpointId ?? (await getOnlyDockerEndpointIdAsync());
|
||||
if (!resolvedEndpointId) notFound();
|
||||
|
||||
try {
|
||||
await api.docker.logs({ id: containerId, tail: 1 });
|
||||
await api.docker.logs({ endpointId: resolvedEndpointId, id: containerId, tail: 1 });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
@@ -40,8 +42,17 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
||||
/>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideDockerLogsTerminal containerId={containerId} />
|
||||
<ClientSideDockerLogsTerminal endpointId={resolvedEndpointId} containerId={containerId} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getOnlyDockerEndpointIdAsync = async () => {
|
||||
try {
|
||||
const endpoints = await api.docker.getEndpoints();
|
||||
return endpoints.length === 1 ? endpoints[0]?.id : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -11,10 +11,11 @@ import { clientApi } from "@homarr/api/client";
|
||||
import classes from "./terminal.module.css";
|
||||
|
||||
interface DockerLogsTerminalProps {
|
||||
endpointId: string;
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
|
||||
export const DockerLogsTerminal = ({ endpointId, containerId }: DockerLogsTerminalProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const pendingLogsRef = useRef<string[]>([]);
|
||||
@@ -31,7 +32,7 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
|
||||
}, []);
|
||||
|
||||
clientApi.docker.subscribeLogs.useSubscription(
|
||||
{ id: containerId, tail: 200 },
|
||||
{ endpointId, id: containerId, tail: 200 },
|
||||
{
|
||||
onData: writeToTerminal,
|
||||
onError(err) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { env } from "@homarr/docker/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { DockerReconciliation } from "./docker-reconciliation";
|
||||
import { DockerTable } from "./docker-table";
|
||||
|
||||
export default async function DockerPage() {
|
||||
@@ -15,7 +16,7 @@ export default async function DockerPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { containers, timestamp } = await api.docker.getContainers();
|
||||
const { containers, endpoints, timestamp } = await api.docker.getContainers();
|
||||
const tDocker = await getScopedI18n("docker");
|
||||
|
||||
return (
|
||||
@@ -23,7 +24,8 @@ export default async function DockerPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable initialData={{ containers, timestamp }} />
|
||||
<DockerReconciliation />
|
||||
<DockerTable initialData={{ containers, endpoints, timestamp }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
+20
-5
@@ -1,31 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
|
||||
import { Alert, SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { KubernetesLabelResourceType } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import KubernetesErrorPage from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/error";
|
||||
import { HeaderCard } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card";
|
||||
import { ResourceGauge } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge";
|
||||
import { ResourceTile } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile";
|
||||
import { useSelectedKubernetesContextId } from "../kubernetes-context-selector";
|
||||
|
||||
export function ClusterDashboard() {
|
||||
const t = useI18n();
|
||||
const tCluster = useScopedI18n("kubernetes.cluster");
|
||||
const contextId = useSelectedKubernetesContextId();
|
||||
|
||||
const {
|
||||
data: clusterData,
|
||||
isLoading: isClusterLoading,
|
||||
isError: isClusterError,
|
||||
} = clientApi.kubernetes.cluster.getCluster.useQuery();
|
||||
} = clientApi.kubernetes.cluster.getCluster.useQuery({ contextId: contextId ?? "" }, { enabled: Boolean(contextId) });
|
||||
|
||||
const {
|
||||
data: resourceCountsData,
|
||||
isLoading: isResourceCountsLoading,
|
||||
isError: isResourceCountsError,
|
||||
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery();
|
||||
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery(
|
||||
{ contextId: contextId ?? "" },
|
||||
{ enabled: Boolean(contextId) },
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack bg="var(--mantine-color-body)">
|
||||
@@ -46,6 +53,12 @@ export function ClusterDashboard() {
|
||||
|
||||
<Title>{t("kubernetes.cluster.capacity.title")}</Title>
|
||||
|
||||
{clusterData?.metricsAvailable === false && (
|
||||
<Alert color="blue" variant="light" icon={<IconInfoCircle size={18} />}>
|
||||
{tCluster("capacity.metricsUnavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{isClusterError
|
||||
? Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />)
|
||||
@@ -63,9 +76,11 @@ export function ClusterDashboard() {
|
||||
? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />)
|
||||
: isResourceCountsLoading
|
||||
? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />)
|
||||
: resourceCountsData?.map((clusterResourceCount) => (
|
||||
: contextId &&
|
||||
resourceCountsData?.map((clusterResourceCount) => (
|
||||
<ResourceTile
|
||||
count={clusterResourceCount.count}
|
||||
contextId={contextId}
|
||||
label={clusterResourceCount.label as KubernetesLabelResourceType}
|
||||
key={clusterResourceCount.label}
|
||||
/>
|
||||
|
||||
+6
-1
@@ -11,13 +11,18 @@ import classes from "./resource-tile.module.css";
|
||||
|
||||
interface ResourceTileProps {
|
||||
count: number;
|
||||
contextId: string;
|
||||
label: KubernetesLabelResourceType;
|
||||
}
|
||||
|
||||
export function ResourceTile(props: ResourceTileProps) {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
|
||||
<Card
|
||||
component={Link}
|
||||
href={`/manage/tools/kubernetes/${props.label}?context=${encodeURIComponent(props.contextId)}`}
|
||||
className={classes.cardContainer}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
||||
<Group gap="xs">
|
||||
|
||||
+12
-7
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ConfigMapsTableComponentProps {
|
||||
contextId: string;
|
||||
initialConfigMaps: RouterOutputs["kubernetes"]["configMaps"]["getConfigMaps"];
|
||||
}
|
||||
|
||||
@@ -39,15 +40,19 @@ const createColumns = (
|
||||
},
|
||||
];
|
||||
|
||||
export function ConfigmapsTable(initialData: ConfigMapsTableComponentProps) {
|
||||
export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTableComponentProps) {
|
||||
const tConfigMaps = useScopedI18n("kubernetes.configmaps");
|
||||
|
||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(undefined, {
|
||||
initialData: initialData.initialConfigMaps,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialConfigMaps,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,14 +8,24 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ConfigmapsTable } from "~/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function ConfigMapsPage() {
|
||||
export default async function ConfigMapsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<KubernetesContextSearchParams>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const configMaps = await api.kubernetes.configMaps.getConfigMaps();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const configMaps =
|
||||
context.status === "unavailable"
|
||||
? []
|
||||
: await api.kubernetes.configMaps.getConfigMaps({ contextId: context.contextId });
|
||||
const tConfigMaps = await getScopedI18n("kubernetes.configmaps");
|
||||
|
||||
return (
|
||||
@@ -23,7 +33,7 @@ export default async function ConfigMapsPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tConfigMaps("label")}</Title>
|
||||
<ConfigmapsTable initialConfigMaps={configMaps} />
|
||||
<ConfigmapsTable contextId={context.contextId} initialConfigMaps={configMaps} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,9 +16,12 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface IngressesTableComponentProps {
|
||||
contextId: string;
|
||||
initialIngresses: RouterOutputs["kubernetes"]["ingresses"]["getIngresses"];
|
||||
}
|
||||
|
||||
@@ -72,15 +75,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.ingresses">): MR
|
||||
},
|
||||
];
|
||||
|
||||
export function IngressesTable(initialData: IngressesTableComponentProps) {
|
||||
export function IngressesTable({ contextId, initialIngresses }: IngressesTableComponentProps) {
|
||||
const tIngresses = useScopedI18n("kubernetes.ingresses");
|
||||
|
||||
const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(undefined, {
|
||||
initialData: initialData.initialIngresses,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialIngresses,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
@@ -103,5 +110,9 @@ export function IngressesTable(initialData: IngressesTableComponentProps) {
|
||||
columns: createColumns(tIngresses),
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return <KubernetesErrorPage />;
|
||||
}
|
||||
|
||||
return <MantineReactTable table={table} />;
|
||||
}
|
||||
@@ -8,21 +8,31 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { IngressesTable } from "~/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function NamespacesPage() {
|
||||
export default async function NamespacesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<KubernetesContextSearchParams>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const ingresses = await api.kubernetes.ingresses.getIngresses();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const ingresses =
|
||||
context.status === "unavailable"
|
||||
? []
|
||||
: await api.kubernetes.ingresses.getIngresses({ contextId: context.contextId });
|
||||
const tIngresses = await getScopedI18n("kubernetes.ingresses");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tIngresses("label")}</Title>
|
||||
<IngressesTable initialIngresses={ingresses} />
|
||||
<IngressesTable contextId={context.contextId} initialIngresses={ingresses} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Alert, Badge, Button, Group, Select, Stack, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const KubernetesContextSelector = () => {
|
||||
const t = useScopedI18n("kubernetes.context");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { data, isError, refetch } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const [storedContextId, setStoredContextId] = useLocalStorage<string | null>({
|
||||
key: "homarr-kubernetes-context",
|
||||
defaultValue: null,
|
||||
});
|
||||
const requestedContextId = searchParams.get("context");
|
||||
const contextId =
|
||||
data?.contexts.find((context) => context.contextId === requestedContextId)?.contextId ??
|
||||
data?.contexts.find((context) => context.contextId === storedContextId)?.contextId ??
|
||||
data?.contexts.find((context) => context.contextId === data.defaultContextId)?.contextId ??
|
||||
data?.contexts[0]?.contextId ??
|
||||
null;
|
||||
const context = data?.contexts.find((item) => item.contextId === contextId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextId || requestedContextId === contextId) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("context", contextId);
|
||||
router.replace(`${pathname}?${next.toString()}`);
|
||||
}, [contextId, pathname, requestedContextId, router, searchParams]);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("loadError.title")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("loadError.message")}</Text>
|
||||
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void refetch()}>
|
||||
{t("loadError.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !contextId) return null;
|
||||
|
||||
const selectContext = (value: string | null) => {
|
||||
if (!value) return;
|
||||
setStoredContextId(value);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("context", value);
|
||||
router.replace(`${pathname}?${next.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="end">
|
||||
<Select
|
||||
label={t("label")}
|
||||
value={contextId}
|
||||
data={data.contexts.map((item) => ({ value: item.contextId, label: item.name }))}
|
||||
onChange={selectContext}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{context && (
|
||||
<Badge color={contextStatusColor(context.status)} variant="light">
|
||||
{t(`status.${context.status}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{context?.status === "unavailable" && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("unavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
{context?.status === "degraded" && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t("metricsUnavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSelectedKubernetesContextId = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const { data } = clientApi.kubernetes.contexts.getContexts.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const requested = searchParams.get("context");
|
||||
if (data?.contexts.some(({ contextId }) => contextId === requested)) return requested;
|
||||
return (
|
||||
data?.contexts.find(({ contextId }) => contextId === data.defaultContextId)?.contextId ??
|
||||
data?.contexts[0]?.contextId
|
||||
);
|
||||
};
|
||||
|
||||
const contextStatusColor = (status: "available" | "degraded" | "unavailable") => {
|
||||
if (status === "available") return "green";
|
||||
if (status === "degraded") return "yellow";
|
||||
return "red";
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
export interface KubernetesContextSearchParams {
|
||||
context?: string | string[];
|
||||
}
|
||||
|
||||
export const getSelectedKubernetesContextAsync = async (searchParams: Promise<KubernetesContextSearchParams>) => {
|
||||
const [{ context }, result] = await Promise.all([searchParams, api.kubernetes.contexts.getContexts()]);
|
||||
const requestedContextId = Array.isArray(context) ? context[0] : context;
|
||||
const selected =
|
||||
result.contexts.find(({ contextId }) => contextId === requestedContextId) ??
|
||||
result.contexts.find(({ contextId }) => contextId === result.defaultContextId) ??
|
||||
result.contexts[0];
|
||||
if (!selected) throw new Error("No Kubernetes context is configured");
|
||||
return selected;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Suspense } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
|
||||
import { KubernetesContextSelector } from "./kubernetes-context-selector";
|
||||
|
||||
export default function KubernetesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Stack>
|
||||
<Suspense fallback={null}>
|
||||
<KubernetesContextSelector />
|
||||
</Suspense>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+12
-7
@@ -18,6 +18,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface NamespacesTableComponentProps {
|
||||
contextId: string;
|
||||
initialNamespaces: RouterOutputs["kubernetes"]["namespaces"]["getNamespaces"];
|
||||
}
|
||||
|
||||
@@ -56,15 +57,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.namespaces">): M
|
||||
},
|
||||
];
|
||||
|
||||
export function NamespacesTable(initialData: NamespacesTableComponentProps) {
|
||||
export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTableComponentProps) {
|
||||
const tNamespaces = useScopedI18n("kubernetes.namespaces");
|
||||
|
||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(undefined, {
|
||||
initialData: initialData.initialNamespaces,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialNamespaces,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,21 +8,31 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function NamespacesPage() {
|
||||
export default async function NamespacesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<KubernetesContextSearchParams>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const namespaces = await api.kubernetes.namespaces.getNamespaces();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const namespaces =
|
||||
context.status === "unavailable"
|
||||
? []
|
||||
: await api.kubernetes.namespaces.getNamespaces({ contextId: context.contextId });
|
||||
const tNamespaces = await getScopedI18n("kubernetes.namespaces");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tNamespaces("label")}</Title>
|
||||
<NamespacesTable initialNamespaces={namespaces} />
|
||||
<NamespacesTable contextId={context.contextId} initialNamespaces={namespaces} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -15,9 +15,12 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface NodesListComponentProps {
|
||||
contextId: string;
|
||||
initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"];
|
||||
}
|
||||
|
||||
@@ -49,14 +52,14 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
|
||||
accessorKey: "allocatableCpuPercentage",
|
||||
header: t("field.cpu.label"),
|
||||
Cell({ cell }) {
|
||||
return getRingProgress(cell.row.original.allocatableCpuPercentage);
|
||||
return getRingProgress(cell.row.original.allocatableCpuPercentage, t("field.metricsUnavailable"));
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "allocatableRamPercentage",
|
||||
header: t("field.memory.label"),
|
||||
Cell({ cell }) {
|
||||
return getRingProgress(cell.row.original.allocatableRamPercentage);
|
||||
return getRingProgress(cell.row.original.allocatableRamPercentage, t("field.metricsUnavailable"));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -82,15 +85,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
|
||||
},
|
||||
];
|
||||
|
||||
export function NodesTable(initialData: NodesListComponentProps) {
|
||||
export function NodesTable({ contextId, initialNodes }: NodesListComponentProps) {
|
||||
const tNodes = useScopedI18n("kubernetes.nodes");
|
||||
|
||||
const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(undefined, {
|
||||
initialData: initialData.initialNodes,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialNodes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
@@ -112,10 +119,18 @@ export function NodesTable(initialData: NodesListComponentProps) {
|
||||
columns: createColumns(tNodes),
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return <KubernetesErrorPage />;
|
||||
}
|
||||
|
||||
return <MantineReactTable table={table} />;
|
||||
}
|
||||
|
||||
function getRingProgress(value: number) {
|
||||
function getRingProgress(value: number | null, unavailableLabel: string) {
|
||||
if (value === null) {
|
||||
return <Text c="dimmed">{unavailableLabel}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RingProgress
|
||||
size={70}
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { NodesTable } from "~/app/[locale]/manage/tools/kubernetes/nodes/nodes-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function NodesPage() {
|
||||
export default async function NodesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const nodes = await api.kubernetes.nodes.getNodes();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const nodes =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.nodes.getNodes({ contextId: context.contextId });
|
||||
const tNodes = await getScopedI18n("kubernetes.nodes");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tNodes("label")}</Title>
|
||||
<NodesTable initialNodes={nodes} />
|
||||
<NodesTable contextId={context.contextId} initialNodes={nodes} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function PodsPage() {
|
||||
export default async function PodsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const pods = await api.kubernetes.pods.getPods();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const pods =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.pods.getPods({ contextId: context.contextId });
|
||||
const tPods = await getScopedI18n("kubernetes.pods");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tPods("label")}</Title>
|
||||
<PodsTable initialPods={pods} />
|
||||
<PodsTable contextId={context.contextId} initialPods={pods} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface PodsTableComponentProps {
|
||||
contextId: string;
|
||||
initialPods: RouterOutputs["kubernetes"]["pods"]["getPods"];
|
||||
}
|
||||
|
||||
@@ -47,15 +48,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.pods">): MRT_Col
|
||||
},
|
||||
];
|
||||
|
||||
export function PodsTable(initialData: PodsTableComponentProps) {
|
||||
export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
|
||||
const tPods = useScopedI18n("kubernetes.pods");
|
||||
|
||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(undefined, {
|
||||
initialData: initialData.initialPods,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialPods,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function SecretsPage() {
|
||||
export default async function SecretsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const secrets = await api.kubernetes.secrets.getSecrets();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const secrets =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.secrets.getSecrets({ contextId: context.contextId });
|
||||
const tSecrets = await getScopedI18n("kubernetes.secrets");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tSecrets("label")}</Title>
|
||||
<SecretsTable initialSecrets={secrets} />
|
||||
<SecretsTable contextId={context.contextId} initialSecrets={secrets} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface SecretsTableComponentProps {
|
||||
contextId: string;
|
||||
initialSecrets: RouterOutputs["kubernetes"]["secrets"]["getSecrets"];
|
||||
}
|
||||
|
||||
@@ -42,15 +43,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.secrets">): MRT_
|
||||
},
|
||||
];
|
||||
|
||||
export function SecretsTable(initialData: SecretsTableComponentProps) {
|
||||
export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponentProps) {
|
||||
const tSecrets = useScopedI18n("kubernetes.secrets");
|
||||
|
||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(undefined, {
|
||||
initialData: initialData.initialSecrets,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialSecrets,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function ServicesPage() {
|
||||
export default async function ServicesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const services = await api.kubernetes.services.getServices();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const services =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.services.getServices({ contextId: context.contextId });
|
||||
const tServices = await getScopedI18n("kubernetes.services");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tServices("label")}</Title>
|
||||
<ServicesTable initialServices={services} />
|
||||
<ServicesTable contextId={context.contextId} initialServices={services} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ServicesTableComponentProps {
|
||||
contextId: string;
|
||||
initialServices: RouterOutputs["kubernetes"]["services"]["getServices"];
|
||||
}
|
||||
|
||||
@@ -61,15 +62,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.services">): MRT
|
||||
},
|
||||
];
|
||||
|
||||
export function ServicesTable(initialData: ServicesTableComponentProps) {
|
||||
export function ServicesTable({ contextId, initialServices }: ServicesTableComponentProps) {
|
||||
const tServices = useScopedI18n("kubernetes.services");
|
||||
|
||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(undefined, {
|
||||
initialData: initialData.initialServices,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialServices,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -8,21 +8,25 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
||||
|
||||
export default async function VolumesPage() {
|
||||
export default async function VolumesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const volumes = await api.kubernetes.volumes.getVolumes();
|
||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
||||
const volumes =
|
||||
context.status === "unavailable" ? [] : await api.kubernetes.volumes.getVolumes({ contextId: context.contextId });
|
||||
const tVolumes = await getScopedI18n("kubernetes.volumes");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tVolumes("label")}</Title>
|
||||
<VolumesTable initialVolumes={volumes} />
|
||||
<VolumesTable contextId={context.contextId} initialVolumes={volumes} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface VolumesTableComponentProps {
|
||||
contextId: string;
|
||||
initialVolumes: RouterOutputs["kubernetes"]["volumes"]["getVolumes"];
|
||||
}
|
||||
|
||||
@@ -66,15 +67,19 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.volumes">): MRT_
|
||||
},
|
||||
];
|
||||
|
||||
export function VolumesTable(initialData: VolumesTableComponentProps) {
|
||||
export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponentProps) {
|
||||
const tVolumes = useScopedI18n("kubernetes.volumes");
|
||||
|
||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(undefined, {
|
||||
initialData: initialData.initialVolumes,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialVolumes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data,
|
||||
|
||||
@@ -1,46 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Alert, Button, Stack } from "@mantine/core";
|
||||
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
|
||||
import { env } from "~/env";
|
||||
|
||||
export default async function WorkshopPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
const t = await getScopedI18n("workshop");
|
||||
const { workshopWebUrl } = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
<Button
|
||||
component="a"
|
||||
href={workshopWebUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Alert icon={<IconInfoCircle size={18} />} title={t("manageSubmissionsTitle")} color="blue">
|
||||
{t("manageSubmissionsDescription")}
|
||||
</Alert>
|
||||
<WorkshopInstaller />
|
||||
</Stack>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
/** The Workshop now lives as a tab of the Custom Widgets page. */
|
||||
export default function WorkshopPage(): never {
|
||||
permanentRedirect("/manage/custom-widgets/workshop");
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import AdmZip from "adm-zip";
|
||||
import Database from "better-sqlite3";
|
||||
import BetterSqlite3 from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
@@ -18,6 +18,9 @@ import { env } from "@homarr/common/env";
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { db } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { applyCustomMigrationsAsync } from "@homarr/db/migrations/custom";
|
||||
import { schema } from "@homarr/db/schema";
|
||||
|
||||
import { findMigrationsFolder } from "../shared";
|
||||
|
||||
@@ -120,7 +123,7 @@ const assertArchiveSizeLimits = (zip: AdmZip) => {
|
||||
}
|
||||
};
|
||||
|
||||
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
||||
const reEncryptSecrets = (tempDb: InstanceType<typeof BetterSqlite3>, importedKeyHex: string) => {
|
||||
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
||||
if (importedKeyHex === currentKeyHex) return;
|
||||
|
||||
@@ -172,7 +175,7 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex:
|
||||
transaction();
|
||||
};
|
||||
|
||||
const getHomeBoardName = (tempDb: InstanceType<typeof Database>): string | null => {
|
||||
const getHomeBoardName = (tempDb: InstanceType<typeof BetterSqlite3>): string | null => {
|
||||
const row = tempDb
|
||||
.prepare(
|
||||
`SELECT "name" FROM (
|
||||
@@ -234,7 +237,7 @@ export async function POST(req: Request) {
|
||||
|
||||
restoreInProgress = true;
|
||||
let tempDirectory: string | null = null;
|
||||
let tempDb: InstanceType<typeof Database> | null = null;
|
||||
let tempDb: InstanceType<typeof BetterSqlite3> | null = null;
|
||||
|
||||
try {
|
||||
let formData: FormData;
|
||||
@@ -285,9 +288,10 @@ export async function POST(req: Request) {
|
||||
}
|
||||
fs.writeFileSync(tempPath, dbEntry.getData());
|
||||
|
||||
tempDb = new Database(tempPath);
|
||||
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
|
||||
tempDb = new BetterSqlite3(tempPath);
|
||||
const drizzleDb = drizzle(tempDb, { casing: DB_CASING, schema });
|
||||
migrate(drizzleDb, { migrationsFolder });
|
||||
await applyCustomMigrationsAsync(drizzleDb as unknown as Database);
|
||||
|
||||
const metadataEntry = zip.getEntry("metadata.json");
|
||||
if (!metadataEntry) {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export const findMigrationsFolder = () => {
|
||||
const cwd = process.cwd();
|
||||
export const findMigrationsFolder = (cwd = process.cwd()) => {
|
||||
const migrationsFolderCandidates = [
|
||||
path.join(cwd, "db/migrations/sqlite"),
|
||||
path.join(cwd, "packages/db/migrations/sqlite"),
|
||||
path.resolve(cwd, "../../db/migrations/sqlite"),
|
||||
path.resolve(cwd, "../../packages/db/migrations/sqlite"),
|
||||
"/app/db/migrations/sqlite",
|
||||
];
|
||||
return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
@@ -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\`).
|
||||
|
||||
**"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.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
boardName: "Home",
|
||||
kind: "audioStats",
|
||||
summary: "Show audio statistics",
|
||||
integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"],
|
||||
integrationIds: ["navidrome-private", "navidrome-1", "audiobookshelf-1", "plex-1"],
|
||||
},
|
||||
settings,
|
||||
[
|
||||
@@ -55,28 +55,28 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
name: "Navidrome",
|
||||
kind: "navidrome",
|
||||
url: "https://music.example.com",
|
||||
permissions: { hasUseAccess: true },
|
||||
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
|
||||
},
|
||||
{
|
||||
id: "audiobookshelf-1",
|
||||
name: "Audiobookshelf",
|
||||
kind: "audiobookshelf",
|
||||
url: "https://books.example.com",
|
||||
permissions: { hasUseAccess: true },
|
||||
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
|
||||
},
|
||||
{
|
||||
id: "plex-1",
|
||||
name: "Plex",
|
||||
kind: "plex",
|
||||
url: "https://plex.example.com",
|
||||
permissions: { hasUseAccess: true },
|
||||
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
|
||||
},
|
||||
{
|
||||
id: "navidrome-private",
|
||||
name: "Private Navidrome",
|
||||
kind: "navidrome",
|
||||
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 { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
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 { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
|
||||
import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts";
|
||||
import { IntegrationSelectModal } from "../integration/integration-select-modal";
|
||||
|
||||
interface AssistantIntegration extends IntegrationSelectOption {
|
||||
permissions: { hasUseAccess: boolean };
|
||||
permissions: NonNullable<IntegrationSelectOption["permissions"]>;
|
||||
}
|
||||
|
||||
export const getAssistantWidgetConfiguration = (
|
||||
@@ -66,6 +68,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
||||
const t = useScopedI18n("common.assistant.configureWidget");
|
||||
const fullT = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const settings = useSettings();
|
||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
||||
@@ -80,10 +83,11 @@ export const AssistantConfigureWidgetTool = ({
|
||||
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||
const missingRequiredIntegration =
|
||||
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({
|
||||
toolCallId,
|
||||
ready: result === undefined && configuration !== null,
|
||||
ready: result === undefined && configuration !== null && !missingRequiredIntegration,
|
||||
completed: result !== undefined,
|
||||
confirm: () => {
|
||||
if (!args || !configuration) return;
|
||||
@@ -154,6 +158,26 @@ export const AssistantConfigureWidgetTool = ({
|
||||
|
||||
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) {
|
||||
return (
|
||||
<Box className={classes.appTool}>
|
||||
@@ -168,9 +192,32 @@ export const AssistantConfigureWidgetTool = ({
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("noIntegrationDescription")}</Text>
|
||||
<Group gap="xs">
|
||||
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
|
||||
{t("manageIntegrations")}
|
||||
</Button>
|
||||
{session?.user.permissions.includes("integration-create") ? (
|
||||
<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
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
@@ -205,28 +252,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
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`)}`,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button mt="md" fullWidth leftSection={<IconSettings size={17} />} onClick={() => openConfigurationModal()}>
|
||||
{t("review")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getStepAfterRestorePreview, waitForServerReadinessAsync } from "./database-restore-flow";
|
||||
|
||||
it("requires confirmation after every restore preview", () => {
|
||||
expect(getStepAfterRestorePreview()).toBe("confirm");
|
||||
});
|
||||
import { waitForServerReadinessAsync } from "./database-restore-flow";
|
||||
|
||||
describe("waitForServerReadinessAsync", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -29,8 +29,6 @@ const RESTART_REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type ServerReadinessResult = "ready" | "timedOut" | "aborted";
|
||||
|
||||
export const getStepAfterRestorePreview = (): RestoreStep => "confirm";
|
||||
|
||||
interface WaitForServerReadinessOptions {
|
||||
restartAfterMs: number;
|
||||
signal: AbortSignal;
|
||||
@@ -256,14 +254,14 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" miw={0}>
|
||||
<IconFileZip size={20} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file?.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
<Button variant="subtle" size="xs" onClick={handleClear} style={{ flexShrink: 0 }}>
|
||||
{t("changeFile")}
|
||||
@@ -291,8 +289,11 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
<>
|
||||
<BackupPreviewPanel analysis={analysis} />
|
||||
<Group justify="flex-end">
|
||||
<Button rightSection={<IconArrowRight size={16} />} onClick={() => setStep(getStepAfterRestorePreview())}>
|
||||
{t("continueToRestore")}
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => (variant === "standalone" ? void handleConfirm() : setStep("confirm"))}
|
||||
>
|
||||
{variant === "standalone" ? t("confirm.submit") : t("continueToRestore")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -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 type { WidgetKind } 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 { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
@@ -15,33 +16,43 @@ export interface CreateItemInput {
|
||||
kind: WidgetKind;
|
||||
options?: Record<string, unknown>;
|
||||
integrationIds?: string[];
|
||||
targetSectionId?: string;
|
||||
advancedOptions?: BoardItemAdvancedOptions;
|
||||
}
|
||||
|
||||
export const createItemCallback =
|
||||
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||
({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
const firstCanvasSection = previous.sections
|
||||
.filter(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.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 = {
|
||||
id: id ?? createId(),
|
||||
kind,
|
||||
options,
|
||||
layouts: itemLayouts,
|
||||
integrationIds,
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
},
|
||||
advancedOptions: advancedOptions ?? { title: null, customCssClasses: [], borderColor: "" },
|
||||
} satisfies Modify<
|
||||
Item,
|
||||
{
|
||||
@@ -59,14 +70,25 @@ const defaultItemSize = { width: 1, height: 1 };
|
||||
|
||||
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 itemSize = getWidgetItemSize(kind);
|
||||
|
||||
return layouts.map((layoutId) => {
|
||||
return layouts.flatMap((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.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 = {
|
||||
width: Math.min(itemSize.width, columnCount),
|
||||
height: itemSize.height,
|
||||
@@ -80,12 +102,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
throw new Error("Your board is full");
|
||||
}
|
||||
|
||||
return {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
return [
|
||||
{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...emptyPosition,
|
||||
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", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
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 { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { integrations: integrationData = [], section } = useSectionContext();
|
||||
const { integrations: integrationData, section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
|
||||
@@ -77,8 +77,10 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
onIntegrationSaved: resetErrorBoundary,
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
integration.permissions.hasUseAccess &&
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
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", () => {
|
||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||
@@ -45,4 +83,20 @@ describe("resolveMatchingIntegrationsAsync", () => {
|
||||
).resolves.toEqual([]);
|
||||
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";
|
||||
|
||||
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 }) => {
|
||||
if (lock.current) return false;
|
||||
lock.current = true;
|
||||
@@ -10,7 +26,9 @@ export const unlockSelection = (lock: { current: boolean }) => {
|
||||
lock.current = false;
|
||||
};
|
||||
|
||||
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
|
||||
export const resolveMatchingIntegrationsAsync = async <
|
||||
TIntegration extends { kind: IntegrationKind; permissions?: { hasUseAccess: boolean } },
|
||||
>({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations,
|
||||
currentData,
|
||||
@@ -24,5 +42,8 @@ export const resolveMatchingIntegrationsAsync = async <TIntegration extends { ki
|
||||
if (!hasIntegrationSupport) return [];
|
||||
|
||||
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,27 +1,58 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
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 type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
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 { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
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 { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
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 { 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 classes from "./item-select-modal.module.css";
|
||||
|
||||
interface ItemSelectModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
@@ -29,6 +60,8 @@ interface ItemSelectModalContentProps {
|
||||
customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined;
|
||||
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
|
||||
isAdmin: boolean;
|
||||
canCreateIntegration: boolean;
|
||||
initialSearch?: string;
|
||||
}
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
@@ -37,16 +70,63 @@ const ItemSelectModalContent = ({
|
||||
customWidgetDefs,
|
||||
ensureIntegrationDataAsync,
|
||||
isAdmin,
|
||||
canCreateIntegration,
|
||||
initialSearch = "",
|
||||
}: 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 selectionLock = useRef(false);
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const settings = useSettings();
|
||||
const { createItem, removeItem } = useItemActions();
|
||||
|
||||
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(
|
||||
() =>
|
||||
@@ -94,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]) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(`custom:${customWidgetDefinition.id}`);
|
||||
@@ -119,9 +242,15 @@ const ItemSelectModalContent = ({
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: configuredOptions });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
const updatedBoard = createItem({
|
||||
id: itemId,
|
||||
kind: "customApi",
|
||||
integrationIds: [],
|
||||
options: configuredOptions,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
@@ -141,6 +270,8 @@ const ItemSelectModalContent = ({
|
||||
|
||||
const handleAdd = async (kind: WidgetKind) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
flowStartedAt.current = performance.now();
|
||||
trackSetup("widget-started", { entryPoint: "board", intent: kind });
|
||||
setLoadingSelection(kind);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
@@ -154,35 +285,89 @@ const ItemSelectModalContent = ({
|
||||
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 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();
|
||||
|
||||
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`)}`,
|
||||
},
|
||||
);
|
||||
openEditor(matchingIntegrations);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
@@ -192,103 +377,123 @@ const ItemSelectModalContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
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();
|
||||
<Flex gap="md" align="stretch">
|
||||
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden" }}
|
||||
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>
|
||||
</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>
|
||||
>
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
description={t("item.create.destination.description")}
|
||||
data={placementOptions}
|
||||
value={targetSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<CatalogItem
|
||||
key={def.id}
|
||||
height={selectGridCardHeight}
|
||||
label={def.name}
|
||||
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) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
{/* Opens in a new tab so unsaved board changes survive the detour. */}
|
||||
<Button
|
||||
component="a"
|
||||
href="/manage/custom-widgets/workshop"
|
||||
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 = ({
|
||||
@@ -296,11 +501,13 @@ const ItemSelectModalFrame = ({
|
||||
innerProps,
|
||||
}: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: { boardId: string };
|
||||
innerProps: { boardId: string; initialIntegrationKind?: IntegrationKind; initialWidgetKind?: WidgetKind };
|
||||
}) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: session } = useSession();
|
||||
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: customWidgetDefs } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: innerProps.boardId },
|
||||
@@ -314,13 +521,23 @@ const ItemSelectModalFrame = ({
|
||||
customWidgetDefs={customWidgetDefs}
|
||||
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
|
||||
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) => (
|
||||
<ItemSelectModalFrame {...props} />
|
||||
)).withOptions({
|
||||
export const ItemSelectModal = createModal<{
|
||||
boardId: string;
|
||||
initialIntegrationKind?: IntegrationKind;
|
||||
initialWidgetKind?: WidgetKind;
|
||||
}>((props) => <ItemSelectModalFrame {...props} />).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -328,10 +545,11 @@ export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onIntent,
|
||||
onFocus,
|
||||
onPointerEnter,
|
||||
disabled,
|
||||
loading,
|
||||
hasMatchingIntegration,
|
||||
connectionStatus,
|
||||
}: {
|
||||
item: {
|
||||
kind: WidgetKind;
|
||||
@@ -341,27 +559,25 @@ const WidgetItem = ({
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onIntent: () => void;
|
||||
onFocus: () => void;
|
||||
onPointerEnter: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
hasMatchingIntegration: boolean;
|
||||
connectionStatus: WidgetConnectionStatus;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||
}}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
aria-busy={loading || undefined}
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={item.name}
|
||||
status={statusLabel}
|
||||
onFocus={onFocus}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
busy={loading}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -375,24 +591,21 @@ const WidgetItem = ({
|
||||
{item.description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<ConnectionStatusBadge status={connectionStatus} />
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</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={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</CatalogItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
|
||||
const t = useI18n();
|
||||
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
|
||||
|
||||
return (
|
||||
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
|
||||
{t(`item.create.connectionStatus.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -405,7 +618,7 @@ const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind
|
||||
const moreCount = integrations.length - countToShow;
|
||||
|
||||
return (
|
||||
<Group gap={2} mt="auto">
|
||||
<Group gap={2}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
|
||||
@@ -6,10 +6,15 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
|
||||
import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal";
|
||||
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
|
||||
|
||||
export const loadWidgetEditModal = () => {
|
||||
if (widgetEditModalPromise) return widgetEditModalPromise;
|
||||
@@ -24,6 +29,59 @@ export const loadWidgetEditModal = () => {
|
||||
|
||||
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||
|
||||
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined;
|
||||
|
||||
const loadIntegrationEditModal = () => {
|
||||
if (integrationEditModalPromise) return integrationEditModalPromise;
|
||||
|
||||
const currentPromise = import("~/components/integration/embedded-integration-edit-modal");
|
||||
integrationEditModalPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
const LazyIntegrationEditModalContent = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: EmbeddedIntegrationEditModalProps;
|
||||
}) => {
|
||||
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
|
||||
const Component = EmbeddedIntegrationEditModal.component;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
integrationEditModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyIntegrationEditModalContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
|
||||
interface LazyWidgetEditModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: WidgetEditModalProps<WidgetKind>;
|
||||
@@ -32,7 +90,20 @@ interface LazyWidgetEditModalContentProps {
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const Component = WidgetEditModal.component;
|
||||
return <Component {...props} />;
|
||||
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
onEditIntegration: (integrationId) =>
|
||||
openIntegrationEditModal({
|
||||
integrationId,
|
||||
onSuccess: props.innerProps.onIntegrationSaved,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
|
||||
@@ -65,4 +136,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -178,8 +178,11 @@ export const WidgetContextMenu = ({
|
||||
),
|
||||
}));
|
||||
},
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
definition.supportedIntegrations?.some((kind) => kind === integration.kind),
|
||||
onIntegrationSaved: handleRefetch,
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
definition.supportedIntegrations?.some((kind) => kind === integration.kind) &&
|
||||
integration.permissions.hasUseAccess,
|
||||
),
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
@@ -192,6 +195,7 @@ export const WidgetContextMenu = ({
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
handleRefetch,
|
||||
openModal,
|
||||
persistBoard,
|
||||
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;
|
||||
};
|
||||
@@ -1,27 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import {
|
||||
applyCustomWidgetSourceSetup,
|
||||
getCustomWidgetSourceSetups,
|
||||
getImportReview,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSecretKind, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
createCustomWidgetSourceSetupValues,
|
||||
CustomWidgetSourceSetupPanel,
|
||||
ImportReviewDialog,
|
||||
isCustomWidgetSourceSetupReady,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetSourceSetupValue } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { CustomWidgetSourceSetupPanel, ImportReviewDialog } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useCustomWidgetImport } from "./use-custom-widget-import";
|
||||
|
||||
interface CustomWidgetImportDialogProps {
|
||||
opened: boolean;
|
||||
widget: HomarrCustomWidgetV2 | null;
|
||||
@@ -29,7 +15,12 @@ interface CustomWidgetImportDialogProps {
|
||||
stackId?: string;
|
||||
zIndex?: number;
|
||||
modalProps?: Pick<ModalProps, "size" | "styles">;
|
||||
labels?: { title?: string; description?: string; cancel?: string; confirm?: string };
|
||||
labels?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
cancel?: string;
|
||||
confirm?: string;
|
||||
};
|
||||
onClose(): void;
|
||||
onImported?(result: { id: string }): void;
|
||||
}
|
||||
@@ -46,62 +37,14 @@ export function CustomWidgetImportDialog({
|
||||
onImported,
|
||||
}: CustomWidgetImportDialogProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const setups = useMemo(() => getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget]);
|
||||
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
|
||||
const configuredWidget = useMemo(() => {
|
||||
if (!widget) return null;
|
||||
return {
|
||||
...widget,
|
||||
sources: applyCustomWidgetSourceSetup(
|
||||
widget.sources,
|
||||
Object.fromEntries(
|
||||
Object.entries(values).map(([sourceId, value]) => [
|
||||
sourceId,
|
||||
{ baseUrl: value.baseUrl, networkScope: value.networkScope },
|
||||
]),
|
||||
),
|
||||
),
|
||||
};
|
||||
}, [values, widget]);
|
||||
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
|
||||
const onSuccess = (result: { id: string }) => {
|
||||
showSuccessNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: legacyId ? t("notification.migrated") : t("notification.imported"),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
|
||||
onImported?.(result);
|
||||
onClose();
|
||||
};
|
||||
const onError = (error: { message?: string }) => {
|
||||
showErrorNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
|
||||
});
|
||||
};
|
||||
const importMutation = clientApi.customWidget.import.useMutation({ onSuccess, onError });
|
||||
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({ onSuccess, onError });
|
||||
const pending = importMutation.isPending || migrateMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setValues(createCustomWidgetSourceSetupValues(setups));
|
||||
}, [opened, setups]);
|
||||
|
||||
const importWidget = () => {
|
||||
if (!configuredWidget) return;
|
||||
const secrets = Object.entries(values).flatMap(([sourceId, value]) =>
|
||||
Object.entries(value.secrets).flatMap(([kind, secret]) =>
|
||||
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
|
||||
),
|
||||
);
|
||||
if (legacyId) migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets });
|
||||
else importMutation.mutate({ widget: configuredWidget, secrets });
|
||||
};
|
||||
const importer = useCustomWidgetImport({
|
||||
widget,
|
||||
legacyId,
|
||||
onImported: (result) => {
|
||||
onImported?.(result);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ImportReviewDialog
|
||||
@@ -109,49 +52,26 @@ export function CustomWidgetImportDialog({
|
||||
stackId={stackId}
|
||||
zIndex={zIndex}
|
||||
{...modalProps}
|
||||
review={review}
|
||||
pending={pending}
|
||||
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
|
||||
review={importer.review}
|
||||
pending={importer.pending}
|
||||
confirmDisabled={!importer.ready}
|
||||
onClose={onClose}
|
||||
onConfirm={importWidget}
|
||||
onConfirm={importer.importWidget}
|
||||
messages={{
|
||||
title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
|
||||
...importer.reviewMessages,
|
||||
description: legacyId
|
||||
? t("importReview.migrationDescription")
|
||||
: (labels?.description ?? t("importReview.description")),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
networkScope: t("importReview.networkScope"),
|
||||
methods: t("importReview.methods"),
|
||||
permissions: t("importReview.permissions"),
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
: (labels?.description ?? importer.reviewMessages.description),
|
||||
title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
|
||||
cancel: labels?.cancel ?? t("importReview.cancel"),
|
||||
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
}}
|
||||
>
|
||||
<CustomWidgetSourceSetupPanel
|
||||
setups={setups}
|
||||
values={values}
|
||||
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
|
||||
messages={{
|
||||
title: t("importReview.sourceSetup.title"),
|
||||
description: t("importReview.sourceSetup.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("importReview.sourceSetup.credentialsOptional"),
|
||||
configured: t("workbench.sources.configured"),
|
||||
secret: (kind) => t(`importReview.credentials.field.${kind}`),
|
||||
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
|
||||
}}
|
||||
setups={importer.setups}
|
||||
values={importer.values}
|
||||
onChange={importer.setValue}
|
||||
messages={importer.setupMessages}
|
||||
/>
|
||||
</ImportReviewDialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const downloadJson = (data: unknown, filename: string) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import {
|
||||
applyCustomWidgetSourceSetup,
|
||||
getCustomWidgetSourceSetups,
|
||||
getImportReview,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSecretKind, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { createCustomWidgetSourceSetupValues, isCustomWidgetSourceSetupReady } from "@homarr/custom-widgets/workbench";
|
||||
import type {
|
||||
CustomWidgetSourceSetupMessages,
|
||||
CustomWidgetSourceSetupValue,
|
||||
ImportReviewContentMessages,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UseCustomWidgetImportOptions {
|
||||
widget: HomarrCustomWidgetV2 | null;
|
||||
/** Set when replacing an existing v1 widget instead of creating a new one. */
|
||||
legacyId?: string;
|
||||
onImported?(result: { id: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single import pipeline behind every "add this custom widget" surface:
|
||||
* source setup, security review and the mutation itself. Keeping it in one hook
|
||||
* means the Workshop page and the file/clipboard dialog can never drift apart.
|
||||
*/
|
||||
export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCustomWidgetImportOptions) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
// Keyed on the sources themselves rather than the widget object: a background
|
||||
// refetch (window focus, or the invalidation a vote triggers) hands us a new but
|
||||
// identical object, and resetting on that would silently wipe credentials the
|
||||
// user is part-way through typing.
|
||||
const sourcesKey = JSON.stringify(widget?.sources ?? {});
|
||||
const setups = useMemo(
|
||||
() => getCustomWidgetSourceSetups(widget?.sources ?? {}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sourcesKey],
|
||||
);
|
||||
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
|
||||
useEffect(() => setValues(createCustomWidgetSourceSetupValues(setups)), [setups]);
|
||||
|
||||
const configuredWidget = useMemo(() => {
|
||||
if (!widget) return null;
|
||||
return {
|
||||
...widget,
|
||||
sources: applyCustomWidgetSourceSetup(
|
||||
widget.sources,
|
||||
Object.fromEntries(
|
||||
Object.entries(values).map(([sourceId, value]) => [
|
||||
sourceId,
|
||||
{ baseUrl: value.baseUrl, networkScope: value.networkScope },
|
||||
]),
|
||||
),
|
||||
),
|
||||
};
|
||||
}, [values, widget]);
|
||||
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
|
||||
|
||||
// A widget must only ever be imported once per visit: the mutation creates a new
|
||||
// record every call, so a second click would silently duplicate it.
|
||||
const [succeeded, setSucceeded] = useState(false);
|
||||
useEffect(() => setSucceeded(false), [widget]);
|
||||
|
||||
const onSuccess = (result: { id: string }) => {
|
||||
setSucceeded(true);
|
||||
showSuccessNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: legacyId ? t("notification.migrated") : t("notification.imported"),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
// Hand over to the caller before the server-side revalidation round-trip so
|
||||
// navigation is immediate rather than waiting on it.
|
||||
onImported?.(result);
|
||||
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
|
||||
};
|
||||
const onError = (error: { message?: string }) => {
|
||||
showErrorNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
|
||||
});
|
||||
};
|
||||
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess,
|
||||
onError,
|
||||
});
|
||||
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({
|
||||
onSuccess,
|
||||
onError,
|
||||
});
|
||||
|
||||
const pending = importMutation.isPending || migrateMutation.isPending;
|
||||
|
||||
const importWidget = () => {
|
||||
if (!configuredWidget || pending || succeeded) return;
|
||||
const secrets = Object.entries(values).flatMap(([sourceId, value]) =>
|
||||
Object.entries(value.secrets).flatMap(([kind, secret]) =>
|
||||
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
|
||||
),
|
||||
);
|
||||
if (legacyId)
|
||||
migrateMutation.mutate({
|
||||
id: legacyId,
|
||||
widget: configuredWidget,
|
||||
secrets,
|
||||
});
|
||||
else importMutation.mutate({ widget: configuredWidget, secrets });
|
||||
};
|
||||
|
||||
const reviewMessages: ImportReviewContentMessages = {
|
||||
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
networkScope: t("importReview.networkScope"),
|
||||
methods: t("importReview.methods"),
|
||||
permissions: t("importReview.permissions"),
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
};
|
||||
|
||||
const setupMessages: CustomWidgetSourceSetupMessages = {
|
||||
title: t("importReview.sourceSetup.title"),
|
||||
description: t("importReview.sourceSetup.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("importReview.sourceSetup.credentialsOptional"),
|
||||
configured: t("workbench.sources.configured"),
|
||||
secret: (kind) => t(`importReview.credentials.field.${kind}`),
|
||||
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
|
||||
};
|
||||
|
||||
return {
|
||||
review,
|
||||
setups,
|
||||
values,
|
||||
setValue: (sourceId: string, value: CustomWidgetSourceSetupValue) =>
|
||||
setValues((current) => ({ ...current, [sourceId]: value })),
|
||||
ready: isCustomWidgetSourceSetupReady(setups, values),
|
||||
pending,
|
||||
/** True once the import went through; callers keep the action locked while navigating away. */
|
||||
succeeded,
|
||||
importWidget,
|
||||
reviewMessages,
|
||||
setupMessages,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
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 { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
|
||||
export interface EmbeddedIntegrationEditModalProps {
|
||||
integrationId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
data: integration,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = clientApi.integration.byId.useQuery({
|
||||
id: innerProps.integrationId,
|
||||
});
|
||||
|
||||
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}
|
||||
embedded
|
||||
onSuccess={() => {
|
||||
innerProps.onSuccess?.();
|
||||
actions.closeModal();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -6,20 +6,25 @@ import { IconArrowLeft } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect } from "@homarr/modals";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
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 { IntegrationCompletionModal } from "./integration-completion-modal";
|
||||
|
||||
interface IntegrationSelectModalProps {
|
||||
onSuccess?: () => void;
|
||||
onSuccess?: (result?: CreatedIntegrationResult) => void;
|
||||
enableMockIntegration?: boolean;
|
||||
allowedKinds?: readonly IntegrationKind[];
|
||||
completionBoardId?: string;
|
||||
}
|
||||
|
||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||
const [step, setStep] = useState<"select" | "form">("select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
|
||||
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
|
||||
|
||||
const handleSelect = (kind: IntegrationKind) => {
|
||||
setSelectedKind(kind);
|
||||
@@ -31,9 +36,13 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
setSelectedKind(null);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
innerProps.onSuccess?.();
|
||||
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
||||
actions.closeModal();
|
||||
if (innerProps.onSuccess) {
|
||||
innerProps.onSuccess(result);
|
||||
} else if (result) {
|
||||
openCompletionModal({ result, boardId: innerProps.completionBoardId });
|
||||
}
|
||||
};
|
||||
|
||||
if (step === "form" && selectedKind) {
|
||||
@@ -49,7 +58,13 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
);
|
||||
}
|
||||
|
||||
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} />;
|
||||
return (
|
||||
<IntegrationSelectGrid
|
||||
onSelect={handleSelect}
|
||||
enableMockIntegration={innerProps.enableMockIntegration}
|
||||
allowedKinds={innerProps.allowedKinds}
|
||||
/>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("integration.action.create"),
|
||||
size: modalSizeSelect,
|
||||
|
||||
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user