Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4789fb680c | ||
|
|
2e99b424fd | ||
|
|
3603d0393c | ||
|
|
447968d2b4 | ||
|
|
79b8066a36 | ||
|
|
c369df0133 | ||
|
|
5f5d71c3fd | ||
|
|
978f117a88 |
No files matched your search
@@ -8,7 +8,7 @@ on:
|
|||||||
types: [closed]
|
types: [closed]
|
||||||
branches: ["**"]
|
branches: ["**"]
|
||||||
push:
|
push:
|
||||||
branches: [main, dev, beta, next, v2, release/v2]
|
branches: [main, dev, beta, next, v2]
|
||||||
merge_group:
|
merge_group:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -105,20 +105,18 @@ jobs:
|
|||||||
|
|
||||||
container:
|
container:
|
||||||
name: Container and E2E
|
name: Container and E2E
|
||||||
needs: [fast-gate, preview-amd64]
|
needs: fast-gate
|
||||||
if: |
|
if: |
|
||||||
!cancelled() && needs.fast-gate.result == 'success' &&
|
github.event_name == 'merge_group' ||
|
||||||
(
|
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||||
github.event_name == 'merge_group' ||
|
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
|
||||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
|
||||||
)
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: read
|
packages: write
|
||||||
|
outputs:
|
||||||
|
digest: ${{ steps.preview-build.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
if: "!env.ACT"
|
if: "!env.ACT"
|
||||||
@@ -133,18 +131,8 @@ jobs:
|
|||||||
if: "!env.ACT"
|
if: "!env.ACT"
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Reuse amd64 preview image
|
|
||||||
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
|
|
||||||
env:
|
|
||||||
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
|
||||||
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
|
|
||||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
|
|
||||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
|
|
||||||
|
|
||||||
- name: Build container once
|
- name: Build container once
|
||||||
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
|
if: "!env.ACT"
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -159,8 +147,8 @@ jobs:
|
|||||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
|
||||||
cache-from: type=gha,scope=homarr-e2e
|
cache-from: type=gha,scope=homarr-linux-amd64
|
||||||
cache-to: type=gha,scope=homarr-e2e,mode=max
|
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
||||||
secrets: |
|
secrets: |
|
||||||
TURBO_API=${{ secrets.TURBO_API }}
|
TURBO_API=${{ secrets.TURBO_API }}
|
||||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||||
@@ -201,10 +189,7 @@ jobs:
|
|||||||
run: pnpm exec playwright install-deps chromium
|
run: pnpm exec playwright install-deps chromium
|
||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
env:
|
run: pnpm test:e2e
|
||||||
NODE_ENV: development
|
|
||||||
CI: "true"
|
|
||||||
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
|
|
||||||
|
|
||||||
- name: Upload E2E artifacts
|
- name: Upload E2E artifacts
|
||||||
if: failure() && !env.ACT
|
if: failure() && !env.ACT
|
||||||
@@ -214,44 +199,27 @@ jobs:
|
|||||||
path: e2e/test-results/
|
path: e2e/test-results/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
preview-amd64:
|
- name: Log in to GHCR for preview publication
|
||||||
name: Build amd64 preview image
|
if: |
|
||||||
needs: fast-gate
|
!env.ACT &&
|
||||||
if: |
|
(
|
||||||
(
|
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
|
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
)
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
|
||||||
)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
outputs:
|
|
||||||
digest: ${{ steps.preview-build.outputs.digest }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
if: "!env.ACT"
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
if: "!env.ACT"
|
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Push amd64 preview image by digest
|
||||||
if: "!env.ACT"
|
|
||||||
uses: docker/setup-buildx-action@v4
|
|
||||||
|
|
||||||
- name: Build and push amd64 preview image by digest
|
|
||||||
id: preview-build
|
id: preview-build
|
||||||
if: "!env.ACT"
|
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')
|
||||||
|
)
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -259,20 +227,22 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
outputs: type=image,"name=${{ env.PREVIEW_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,"name=${{ env.PREVIEW_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha,scope=homarr-linux-amd64
|
cache-from: type=gha,scope=homarr-linux-amd64
|
||||||
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
|
|
||||||
secrets: |
|
secrets: |
|
||||||
TURBO_API=${{ secrets.TURBO_API }}
|
TURBO_API=${{ secrets.TURBO_API }}
|
||||||
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
|
||||||
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||||
|
|
||||||
|
- name: Show Act publication result
|
||||||
|
if: env.ACT
|
||||||
|
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
|
||||||
|
|
||||||
preview-arm64:
|
preview-arm64:
|
||||||
name: Build arm64 preview image
|
name: Build arm64 preview image
|
||||||
needs: fast-gate
|
needs: fast-gate
|
||||||
if: |
|
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 == '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')
|
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||||
)
|
)
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
@@ -320,11 +290,10 @@ jobs:
|
|||||||
|
|
||||||
publish-preview:
|
publish-preview:
|
||||||
name: Publish multi-platform preview image
|
name: Publish multi-platform preview image
|
||||||
needs: [preview-amd64, preview-arm64]
|
needs: [container, preview-arm64]
|
||||||
if: |
|
if: |
|
||||||
(
|
(
|
||||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
|
||||||
)
|
)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -334,7 +303,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
env:
|
env:
|
||||||
PREVIEW_TAG: ${{ github.event_name == 'push' && 'v2' || format('pr-{0}', github.event.pull_request.number || inputs.pr_number) }}
|
PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
if: "!env.ACT"
|
if: "!env.ACT"
|
||||||
@@ -371,7 +340,7 @@ jobs:
|
|||||||
- name: Publish preview manifest
|
- name: Publish preview manifest
|
||||||
if: "!env.ACT"
|
if: "!env.ACT"
|
||||||
env:
|
env:
|
||||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
AMD64_DIGEST: ${{ needs.container.outputs.digest }}
|
||||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
permissions:
|
permissions:
|
||||||
statuses: write
|
statuses: write
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -17,20 +16,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||||
run: |
|
run: |
|
||||||
for attempt in 1 2 3 4 5; do
|
gh api \
|
||||||
if gh api \
|
--method POST \
|
||||||
--method POST \
|
-H "Accept: application/vnd.github+json" \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
|
||||||
/repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \
|
-f 'state=success' \
|
||||||
-f 'state=success' \
|
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
|
||||||
-f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \
|
-f 'description=Skipped for non renovate branches' \
|
||||||
-f 'description=Skipped for non renovate branches' \
|
-f 'context=renovate/stability-days'
|
||||||
-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
|
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -58,12 +58,11 @@ A random secret can be generated by using the following command: `openssl rand -
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||||
| `DOCKER_SOCKET_PATHS` | Comma-separated mounted Docker-compatible socket paths. Homarr discovers each path as a selectable Docker environment | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||||
| `DOCKER_HOSTNAMES` | Comma-separated plaintext TCP hostnames. Pair each hostname with the port in the same position in `DOCKER_PORTS` | For example `localhost,docker.example.com` | - |
|
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
||||||
| `DOCKER_PORTS` | Comma-separated plaintext TCP ports paired with `DOCKER_HOSTNAMES` | 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` | - |
|
||||||
| `DOCKER_ENDPOINTS` | Optional advanced JSON configuration for custom names, TLS, or restricted capabilities. It takes precedence over the simple variables above. See the [Docker and Podman connection guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
|
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers";
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
## Connecting Docker to Homarr
|
## Enabling the Docker integration
|
||||||
|
|
||||||
Docker connections come directly from Homarr's environment variables. They are automatically available in the Docker management table and Docker widget; you do not create a Docker integration under **Management → Integrations**.
|
This integration must first be enabled.
|
||||||
|
If you directly run on the host system and you have docker installed, it will be enabled by default.
|
||||||
If Homarr runs directly on a host with Docker installed, it uses `/var/run/docker.sock` by default. When Homarr runs in a container, mount the [Docker socket](https://docs.docker.com/engine/security/protect-access/) into it so Homarr can interact with Docker.
|
Otherwise, you'll need to mount the [docker socket](https://docs.docker.com/engine/security/protect-access/) to the Homarr container,
|
||||||
|
so Homarr can interact with the Docker service.
|
||||||
|
|
||||||
In Docker Compose, you can simply add this line:
|
In Docker Compose, you can simply add this line:
|
||||||
|
|
||||||
@@ -121,89 +122,9 @@ services:
|
|||||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||||
```
|
```
|
||||||
|
|
||||||
For remote socket proxies, list the hostnames and matching ports in the same order:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
DOCKER_HOSTNAMES: docker-proxy-home,docker-proxy-lab
|
|
||||||
DOCKER_PORTS: 2375,2375
|
|
||||||
```
|
|
||||||
|
|
||||||
Homarr automatically discovers each comma-separated entry as a separate Docker environment. All environments are selected by default; the Docker widget lets you select one or more without any additional setup.
|
|
||||||
|
|
||||||
:::warning[Plaintext TCP]
|
|
||||||
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy on a private Docker network, or TLS.
|
|
||||||
:::
|
|
||||||
|
|
||||||
### Advanced endpoint configuration
|
|
||||||
|
|
||||||
Most users should use the comma-separated variables above. Use `DOCKER_ENDPOINTS` only when you need custom names, TLS, or restricted capabilities. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
homarr:
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ./docker-ca.pem:/run/secrets/docker-ca.pem:ro
|
|
||||||
environment:
|
|
||||||
DOCKER_ENDPOINTS: >-
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "local",
|
|
||||||
"name": "Local Docker",
|
|
||||||
"kind": "docker",
|
|
||||||
"transport": { "type": "socket", "path": "/var/run/docker.sock" },
|
|
||||||
"capabilities": ["inventory", "logs", "lifecycle", "remove"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "remote-read-only",
|
|
||||||
"name": "Production inventory",
|
|
||||||
"kind": "docker",
|
|
||||||
"transport": {
|
|
||||||
"type": "tls",
|
|
||||||
"host": "docker.example.com",
|
|
||||||
"port": 2376,
|
|
||||||
"caPath": "/run/secrets/docker-ca.pem"
|
|
||||||
},
|
|
||||||
"capabilities": ["inventory", "logs"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
The stable `id` keeps containers from different endpoints distinct. `name` is shown in the interface, and `kind` can be `docker` or `podman`. Every endpoint must include the `inventory` capability; optionally add `logs`, `lifecycle`, and `remove`. Homarr hides or disables actions the endpoint does not allow and also enforces the same capabilities on the server.
|
|
||||||
|
|
||||||
Supported transports are:
|
|
||||||
|
|
||||||
- `socket`: an absolute in-container `path`
|
|
||||||
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
|
|
||||||
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
|
|
||||||
|
|
||||||
The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. When `DOCKER_ENDPOINTS` is set, it takes precedence over `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS`.
|
|
||||||
|
|
||||||
### Managing your Docker containers within Homarr
|
### Managing your Docker containers within Homarr
|
||||||
|
|
||||||
Homarr allows you to interact with Docker containers running on your system.
|
Homarr allows you to interact with Docker containers running on your system.
|
||||||
The collapsed **Assisted Docker setup** helper optionally compares visible containers with your existing Homarr apps and integrations. Open it only when you want setup suggestions; it never blocks normal container management. Every service stays distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
|
|
||||||
|
|
||||||
Homarr can show a service as:
|
|
||||||
|
|
||||||
- **Ready to connect** when its image or name matches a supported integration
|
|
||||||
- **New service** when it can be added as an app but no integration is recognized
|
|
||||||
- **Already represented** or **Linked** when an app or integration already matches
|
|
||||||
- **Address changed** when a service appears to match an integration but its reachable address has changed
|
|
||||||
|
|
||||||
Set the usual server address once, then choose whether services normally use different ports, subdomains, or paths. Homarr uses that pattern to prefill every detected service. Each suggestion has one editable URL field with the generated and Docker-reported addresses available as autocomplete options, so you can select a suggestion or type any address directly.
|
|
||||||
|
|
||||||
:::info[Review before changing]
|
|
||||||
Service discovery is read-only. Homarr never creates or changes an app or integration automatically. **Set up integration** opens the standard prefilled integration form, while **Create app** opens the existing Docker app form, so you can review and confirm the final setup.
|
|
||||||
:::
|
|
||||||
|
|
||||||
Suggestions appear as a compact task list. Expand an item only when you want to edit its URL or inspect relevant setup statuses. Use the filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same helper. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
|
|
||||||
|
|
||||||
Compact status icons summarize the setup information Homarr can act on. Unknown and non-applicable runtime layers are omitted to keep the helper focused; use the integration form's connection test or the relevant board action for live diagnostics.
|
|
||||||
|
|
||||||
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
|
|
||||||
|
|
||||||
Each container row has an action menu with the following options:
|
Each container row has an action menu with the following options:
|
||||||
|
|
||||||
- **View logs** - opens a dedicated logs page with a live terminal
|
- **View logs** - opens a dedicated logs page with a live terminal
|
||||||
@@ -227,10 +148,6 @@ This opens a dedicated page with an xterm.js terminal that streams the container
|
|||||||
The Docker management table supports optional **CPU usage** and **Memory usage** columns.
|
The Docker management table supports optional **CPU usage** and **Memory usage** columns.
|
||||||
These columns are hidden by default and can be toggled on from the table column visibility settings.
|
These columns are hidden by default and can be toggled on from the table column visibility settings.
|
||||||
|
|
||||||
#### Choosing Docker environments in the widget
|
|
||||||
|
|
||||||
The Docker widget can show one or more configured Docker environments. Leave **Docker environments** empty to include every endpoint, which is also the default for existing widgets. Selecting one or more entries limits container and statistics requests to those endpoints.
|
|
||||||
|
|
||||||
#### Add apps from containers
|
#### Add apps from containers
|
||||||
|
|
||||||
Homarr also lets you add apps from containers. You can simply select all containers you want to add an app for and then click the `Add to Homarr` button.
|
Homarr also lets you add apps from containers. You can simply select all containers you want to add an app for and then click the `Add to Homarr` button.
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import { kubernetesIntegration } from ".";
|
|||||||
|
|
||||||
Homarr provides a user-friendly Kubernetes dashboard that offers an overview of your cluster’s resources in a clear and structured interface. It allows users to view key Kubernetes objects, including `Pods`, `Services`, `Ingresses`, `Nodes`, `ConfigMaps`, `Secrets`, `Namespaces`, and `Volumes`, as well as basic metrics when the Kubernetes Metrics Server is enabled.
|
Homarr provides a user-friendly Kubernetes dashboard that offers an overview of your cluster’s resources in a clear and structured interface. It allows users to view key Kubernetes objects, including `Pods`, `Services`, `Ingresses`, `Nodes`, `ConfigMaps`, `Secrets`, `Namespaces`, and `Volumes`, as well as basic metrics when the Kubernetes Metrics Server is enabled.
|
||||||
|
|
||||||
Docker service discovery does not convert Kubernetes resources into Homarr apps or integrations. Kubernetes remains a separate, read-only operational view, so a cluster resource is never changed or linked automatically.
|
|
||||||
|
|
||||||
:::warning[Read only]
|
:::warning[Read only]
|
||||||
This integration is read-only, meaning Homarr does not modify or manage cluster resources but serves as an informational tool to enhance visibility into your Kubernetes environment.
|
This integration is read-only, meaning Homarr does not modify or manage cluster resources but serves as an informational tool to enhance visibility into your Kubernetes environment.
|
||||||
:::
|
:::
|
||||||
@@ -27,7 +25,7 @@ This integration is read-only, meaning Homarr does not modify or manage cluster
|
|||||||
Before deploying Homarr in Kubernetes, ensure the following prerequisites are met:
|
Before deploying Homarr in Kubernetes, ensure the following prerequisites are met:
|
||||||
|
|
||||||
- Kubernetes (Version >= 1.24.0)
|
- Kubernetes (Version >= 1.24.0)
|
||||||
- Metrics Server ([install from here](https://github.com/kubernetes-sigs/metrics-server)) is optional. Without it, Homarr still shows cluster inventory, node status, and reserved capacity, and identifies live CPU and memory usage as unavailable.
|
- Metrics Server ([Install from here](https://github.com/kubernetes-sigs/metrics-server))
|
||||||
- RBAC Enabled (Required for Homarr Helm chart)
|
- RBAC Enabled (Required for Homarr Helm chart)
|
||||||
|
|
||||||
### Configuring RBAC in Helm Chart
|
### Configuring RBAC in Helm Chart
|
||||||
@@ -47,25 +45,6 @@ Enabling RBAC in the Helm chart creates the necessary Kubernetes Role-Based Acce
|
|||||||
- ClusterRole & ClusterRoleBinding: Extends the same permissions cluster-wide for additional flexibility.
|
- ClusterRole & ClusterRoleBinding: Extends the same permissions cluster-wide for additional flexibility.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Multiple Kubernetes contexts
|
|
||||||
|
|
||||||
When Homarr runs outside a cluster, set `KUBECONFIG` explicitly so Homarr loads the contexts from the referenced kubeconfig file or files. Mount the kubeconfig read-only and set the standard `KUBECONFIG` environment variable to its path inside the Homarr container:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
homarr:
|
|
||||||
volumes:
|
|
||||||
- ./kubeconfig:/app/config/kubeconfig:ro
|
|
||||||
environment:
|
|
||||||
KUBECONFIG: /app/config/kubeconfig
|
|
||||||
```
|
|
||||||
|
|
||||||
The Kubernetes management pages include a context selector. The selected context is stored in the page URL, so each React Query cache entry and every API request remains context-specific. Resources with the same namespace and name in two contexts therefore remain separate.
|
|
||||||
|
|
||||||
Context status is checked independently. If one context is unavailable, the selector still lists healthy contexts. A context is shown as **Metrics unavailable** when its Kubernetes API is reachable but Metrics Server is not; inventory remains available without live CPU and memory usage.
|
|
||||||
|
|
||||||
When Homarr runs inside Kubernetes without `KUBECONFIG`, it keeps using the in-cluster service account as the single default context.
|
|
||||||
|
|
||||||
### Homarr Dashboard Overview
|
### Homarr Dashboard Overview
|
||||||
|
|
||||||
Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes:
|
Once deployed, Homarr provides a dashboard displaying Kubernetes metrics and resource information. The main page includes:
|
||||||
|
|||||||
@@ -86,34 +86,6 @@ Board layout responses identify each layout with a `mobile`, `base`, or `custom`
|
|||||||
|
|
||||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||||
|
|
||||||
## Docker action payload migration
|
|
||||||
|
|
||||||
Docker lifecycle and removal actions now identify each container with both its Docker endpoint and container ID. This is a
|
|
||||||
breaking change for tRPC and MCP clients that previously sent an `ids` array to the start, stop, restart, or remove action.
|
|
||||||
|
|
||||||
Replace the old payload:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ids": ["container-id"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
with endpoint-qualified targets:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"targets": [{ "endpointId": "local", "id": "container-id" }]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Read `endpointId` and `id` from `docker.getContainers` (MCP tool `docker_getContainers`) immediately before the action.
|
|
||||||
Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.restartAll`, or `docker.removeAll`; their MCP tool
|
|
||||||
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
|
|
||||||
Docker or Podman endpoints from targeting the wrong service.
|
|
||||||
|
|
||||||
`docker.getContainers` also accepts an optional `endpointIds` array. Omit it or pass an empty array to query every configured
|
|
||||||
Docker environment, or pass one or more IDs returned by `docker.getEndpoints` to query only those Dockerode clients. The
|
|
||||||
Docker widget uses the same behavior for its environment selector.
|
|
||||||
|
|
||||||
## Partial integration failures
|
## Partial integration failures
|
||||||
|
|
||||||
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
|
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
|
||||||
|
|||||||
@@ -53,4 +53,4 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
|
|||||||
|
|
||||||
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
|
||||||
|
|
||||||
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
|
Viewing this list requires the `app-modify-all` permission. Creating an app requires `app-create`, editing requires `app-modify-all`, and deleting requires `app-full-all`; board permissions are not required.
|
||||||
@@ -204,15 +204,6 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
|
|||||||
style={{ maxWidth: "100%" }}
|
style={{ maxWidth: "100%" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
|
||||||
Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
|
||||||
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
|
|
||||||
destinations before the widget is created, so the common flow does not require a second move step.
|
|
||||||
|
|
||||||
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
|
||||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
|
||||||
saved integration changes apply everywhere that connection is used.
|
|
||||||
|
|
||||||
Pointer users can drag from the inert widget area; interactive controls are excluded. Containers use the grab button at their top-left corner.
|
Pointer users can drag from the inert widget area; interactive controls are excluded. Containers use the grab button at their top-left corner.
|
||||||
Widgets can be resized from any edge or corner handle. Activate a Container's chrome with its top-left grip before using
|
Widgets can be resized from any edge or corner handle. Activate a Container's chrome with its top-left grip before using
|
||||||
its resize handles.
|
its resize handles.
|
||||||
@@ -227,11 +218,7 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
|
|||||||
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
|
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
|
||||||
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
||||||
|
|
||||||
<img
|
<img src={keyboardTileEditingImage} alt="A focused board tile in keyboard editing mode" style={{ maxWidth: "100%" }} />
|
||||||
src={keyboardTileEditingImage}
|
|
||||||
alt="A focused board tile in keyboard editing mode"
|
|
||||||
style={{ maxWidth: "100%" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
#### Preview
|
#### Preview
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ To create an integration, choose **New integration** and select the supported ap
|
|||||||
|
|
||||||
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
|
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
|
||||||
|
|
||||||
|
This page is only visible to users with the global `integration-full-all` permission or with full access on at least one integration. Without the global permission, only the integrations you have full access to are shown.
|
||||||
|
|
||||||
Next, you'll need to fill out the required fields to configure the integration:
|
Next, you'll need to fill out the required fields to configure the integration:
|
||||||
|
|
||||||

|

|
||||||
@@ -59,22 +61,6 @@ It is not allowed to create an integration without passing the test.
|
|||||||
This is to ensure that you minimize the amount of broken applications on your Homarr instance.
|
This is to ensure that you minimize the amount of broken applications on your Homarr instance.
|
||||||
The test and create button will perform a network request to the application and authenticate if required.
|
The test and create button will perform a network request to the application and authenticate if required.
|
||||||
|
|
||||||
When you connect a service from a board, Homarr keeps you in the same setup flow. After the connection succeeds, it
|
|
||||||
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
|
|
||||||
widget returns to its settings with the new integration already selected.
|
|
||||||
|
|
||||||
Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
|
|
||||||
tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
|
|
||||||
Successful changes apply to every app and widget that uses the integration.
|
|
||||||
|
|
||||||
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
|
|
||||||
create one.
|
|
||||||
|
|
||||||
When the connection unlocks useful widgets that are not on the current board yet, the confirmation also shows up to
|
|
||||||
three explainable recommendations. A recommendation is based only on the services you can use and the widget kinds
|
|
||||||
already present on that board. You can open the existing widget picker prefiltered to the recommendation, or dismiss
|
|
||||||
recommendations for that service on that board.
|
|
||||||
|
|
||||||
## Managing permissions
|
## Managing permissions
|
||||||
|
|
||||||
On the edit page of an integration, you can manage which users / groups can
|
On the edit page of an integration, you can manage which users / groups can
|
||||||
|
|||||||
@@ -163,19 +163,59 @@ Members can be removed from the group using the remove button at the end:
|
|||||||
#### Permissions tab
|
#### Permissions tab
|
||||||
|
|
||||||
In this tab, you manage what permissions the members of this group will have.
|
In this tab, you manage what permissions the members of this group will have.
|
||||||
Currently, the following permissions are available:
|
Permissions are organized by category (boards, apps, integrations, and so on). Each category is a row with a
|
||||||
|
segmented control that lets you pick a single access level. Selecting a level grants that level and every lower
|
||||||
|
level in the same category. Choosing **None** removes all permissions for that category.
|
||||||
|
|
||||||
- Boards
|
Selecting **Administrator** grants full access to everything. While the administrator level is selected, every
|
||||||
- **Create boards**: Allow members to create boards
|
other category is disabled and shows a **Granted via Administrator** caption, because administrators already have
|
||||||
|
all permissions. Deselecting it (choosing **None** on the administrator row) re-enables the other categories.
|
||||||
|
|
||||||
|
##### Presets
|
||||||
|
|
||||||
|
Above the rows you can apply a preset to fill in a common configuration in one click:
|
||||||
|
|
||||||
|
- **Viewer**: view access to boards, use access to apps and integrations, and viewing media.
|
||||||
|
- **Editor**: modify access to boards and apps, interact with integrations, modify search engines (includes creating), and viewing media.
|
||||||
|
- **Admin**: grants the administrator permission (full access to everything).
|
||||||
|
|
||||||
|
When the current selection does not match any preset, a **Custom** badge is shown.
|
||||||
|
|
||||||
|
##### Implied permissions
|
||||||
|
|
||||||
|
Some levels include additional permissions automatically. When a level implies extra permissions (for example,
|
||||||
|
full board access also allows creating boards), a dimmed `also grants: …` hint is shown under the category row.
|
||||||
|
|
||||||
|
##### Effective permissions
|
||||||
|
|
||||||
|
Below the rows, the **Effective permissions** panel can be expanded to review everything members of this group
|
||||||
|
will be able to do, including implied permissions, grouped by category with a count for each.
|
||||||
|
|
||||||
|
Each category below is a segmented control that starts with **None**; the remaining segments are the access levels
|
||||||
|
in ascending order (higher levels include the lower ones and may imply extra permissions shown in the hint):
|
||||||
|
|
||||||
|
- Boards — None
|
||||||
- **View all boards**: Allow members to view all boards
|
- **View all boards**: Allow members to view all boards
|
||||||
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
|
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
|
||||||
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone)
|
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone; includes creating boards)
|
||||||
- Integrations
|
- Apps — None
|
||||||
- **Create integrations**: Allow members to create integrations
|
- **Use all apps**: Allow members to add any apps to their boards
|
||||||
- **Use all integrations**: Allows members to add any integrations to their boards
|
- **Modify all apps**: Allow members to modify all apps (includes creating apps)
|
||||||
|
- **Full app access**: Allow members to manage, use and delete any app
|
||||||
|
- Integrations — None
|
||||||
|
- **Use all integrations**: Allow members to add any integrations to their boards
|
||||||
- **Interact with any integration**: Allow members to interact with any integration
|
- **Interact with any integration**: Allow members to interact with any integration
|
||||||
- **Full integration access**: Allow members to manage, use and interact with any integration
|
- **Full integration access**: Allow members to manage, use and interact with any integration (includes creating integrations)
|
||||||
- **Administrator**: Members with this permission have full access to all features and settings
|
- Search engines — None
|
||||||
|
- **Modify all search engines**: Allow members to modify all search engines (includes creating search engines)
|
||||||
|
- **Full search engine access**: Allow members to manage and delete any search engine
|
||||||
|
- Medias — None
|
||||||
|
- **View all medias**: Allow members to view all medias
|
||||||
|
- **Full media access**: Allow members to manage and delete any media (includes uploading medias)
|
||||||
|
- Other — None
|
||||||
|
- **View logs**: Allow members to view logs
|
||||||
|
- Administrator — None
|
||||||
|
- **Administrator**: Members with this permission have full access to all features and settings
|
||||||
|
|
||||||
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
|
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
|
||||||
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
|
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
|
||||||
|
|||||||
@@ -7,15 +7,20 @@ hide_title: true
|
|||||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||||
|
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||||
|
import { IconAd } from "@tabler/icons-react";
|
||||||
|
import Tabs from "@theme/Tabs";
|
||||||
|
import TabItem from "@theme/TabItem";
|
||||||
import { dockerContainersWidget } from ".";
|
import { dockerContainersWidget } from ".";
|
||||||
|
import { dockerIntegration } from "@site/docs/integrations/docker";
|
||||||
|
|
||||||
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
|
||||||
|
|
||||||
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
This widget displays the resource usage of your Docker containers and allows you to manage them.
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
|
This widget can only be used with administrator privileges.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
@@ -24,6 +29,16 @@ import dockerTable from "./img/table.png";
|
|||||||
|
|
||||||
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
|
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
|
||||||
|
|
||||||
|
### Supported Integrations
|
||||||
|
|
||||||
|
<WidgetIntegrations
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
integration: dockerIntegration,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
### Interactions
|
### Interactions
|
||||||
|
|
||||||
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
||||||
|
|||||||
@@ -8,12 +8,6 @@ export const dockerContainersWidget: WidgetDefinition = {
|
|||||||
path: "../../widgets/docker-containers",
|
path: "../../widgets/docker-containers",
|
||||||
configuration: {
|
configuration: {
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
name: "Docker environments",
|
|
||||||
description: "Select one or more automatically discovered Docker environments, or leave empty to show all",
|
|
||||||
values: "Any Docker socket or host configured for Homarr",
|
|
||||||
defaultValue: "All Docker environments",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Columns to show",
|
name: "Columns to show",
|
||||||
description: "Select which columns are visible in the table",
|
description: "Select which columns are visible in the table",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
|||||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||||
|
|
||||||
The media server widget shows active sessions across your media servers.
|
The media server widget shows active sessions across your media servers.
|
||||||
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||||
|
|
||||||
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
|
||||||
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
|
||||||
|
|||||||
@@ -1266,15 +1266,9 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"boardId": {
|
"boardId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"layoutId": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["boardId", "name", "layoutId"],
|
"required": ["boardId"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,6 @@
|
|||||||
"@mantine/form": "catalog:",
|
"@mantine/form": "catalog:",
|
||||||
"@mantine/hooks": "catalog:",
|
"@mantine/hooks": "catalog:",
|
||||||
"@mantine/modals": "catalog:",
|
"@mantine/modals": "catalog:",
|
||||||
"@mantine/notifications": "catalog:",
|
|
||||||
"@mantine/tiptap": "catalog:",
|
"@mantine/tiptap": "catalog:",
|
||||||
"@modelcontextprotocol/server": "catalog:",
|
"@modelcontextprotocol/server": "catalog:",
|
||||||
"@rexa-developer/tiks": "catalog:",
|
"@rexa-developer/tiks": "catalog:",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure, useMounted } from "@mantine/hooks";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
@@ -32,6 +32,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isError = searchParams.has("error");
|
const isError = searchParams.has("error");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isMounted = useMounted();
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const form = useZodForm(extendedValidation, {
|
const form = useZodForm(extendedValidation, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -139,7 +140,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
|||||||
|
|
||||||
{providers.includes("credentials") && (
|
{providers.includes("credentials") && (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<SubmitButton isPending={isPending} form={form} provider="credentials">
|
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="credentials">
|
||||||
{t("action.login.label")}
|
{t("action.login.label")}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
<PasswordForgottenCollapse username={form.values.name} />
|
<PasswordForgottenCollapse username={form.values.name} />
|
||||||
@@ -147,7 +148,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{providers.includes("ldap") && (
|
{providers.includes("ldap") && (
|
||||||
<SubmitButton isPending={isPending} form={form} provider="ldap">
|
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="ldap">
|
||||||
{t("action.login.labelWith", { provider: "LDAP" })}
|
{t("action.login.labelWith", { provider: "LDAP" })}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
)}
|
)}
|
||||||
@@ -158,7 +159,13 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{providers.includes("oidc") && (
|
{providers.includes("oidc") && (
|
||||||
<Button type="button" fullWidth variant="light" disabled={isPending} onClick={() => void signInAsync("oidc")}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
fullWidth
|
||||||
|
variant="light"
|
||||||
|
disabled={!isMounted || isPending}
|
||||||
|
onClick={() => void signInAsync("oidc")}
|
||||||
|
>
|
||||||
{t("action.login.labelWith", { provider: oidcClientName })}
|
{t("action.login.labelWith", { provider: oidcClientName })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -168,12 +175,13 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface SubmitButtonProps {
|
interface SubmitButtonProps {
|
||||||
|
isMounted: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
form: UseFormReturnType<FormType>;
|
form: UseFormReturnType<FormType>;
|
||||||
provider: "credentials" | "ldap";
|
provider: "credentials" | "ldap";
|
||||||
}
|
}
|
||||||
|
|
||||||
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||||
const isCurrentProviderActive = form.getValues().provider === provider;
|
const isCurrentProviderActive = form.getValues().provider === provider;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -184,7 +192,7 @@ const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren
|
|||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => form.setFieldValue("provider", provider)}
|
onClick={() => form.setFieldValue("provider", provider)}
|
||||||
loading={isPending && isCurrentProviderActive}
|
loading={isPending && isCurrentProviderActive}
|
||||||
disabled={isPending && !isCurrentProviderActive}
|
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||||
import { BoardEmptyState } from "~/components/board/board-empty-state";
|
|
||||||
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
|
||||||
import {
|
import {
|
||||||
getBoardLaneColumnCount,
|
getBoardLaneColumnCount,
|
||||||
getInitialBoardLogicalHeight,
|
getInitialBoardLogicalHeight,
|
||||||
@@ -66,8 +64,6 @@ export const ClientBoard = () => {
|
|||||||
<BoardAdvancedFocusProvider>
|
<BoardAdvancedFocusProvider>
|
||||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||||
<BoardBackgroundVideo />
|
<BoardBackgroundVideo />
|
||||||
<BoardEmptyState />
|
|
||||||
<BoardSetupChecklist />
|
|
||||||
<BoardSectionCollapseProvider>
|
<BoardSectionCollapseProvider>
|
||||||
<BoardGridPortalHost>
|
<BoardGridPortalHost>
|
||||||
<ScaledBoardCanvas
|
<ScaledBoardCanvas
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { Group, Menu } from "@mantine/core";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||||
import { IconPlus } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { AppSelectModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
|
import { useItemActions } from "~/components/board/items/item-actions";
|
||||||
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
|
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 { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
|
|
||||||
export default function BoardEditActions() {
|
export default function BoardEditActions() {
|
||||||
@@ -17,41 +20,60 @@ export default function BoardEditActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AddMenu = () => {
|
const AddMenu = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { openModal } = useModalAction(UniversalCreateModal);
|
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||||
|
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||||
|
const { addContainer } = useContainerActions();
|
||||||
|
const { createItem } = useItemActions();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const handledInitialAdd = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSelectItem = () => {
|
||||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
openItemSelectModal({ boardId: board.id });
|
||||||
handledInitialAdd.current = true;
|
};
|
||||||
addWidget();
|
|
||||||
|
|
||||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
const handleSelectApp = () => {
|
||||||
nextSearchParams.delete("add");
|
openAppSelectModal({
|
||||||
const query = nextSearchParams.toString();
|
onSelect: (app) => {
|
||||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
createItem({
|
||||||
}, [addWidget, board.id, pathname, router, searchParams]);
|
kind: "app",
|
||||||
|
options: { appId: app.id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddIntegration = () => {
|
||||||
|
openIntegrationSelectModal({});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderButton
|
<Menu position="bottom-end">
|
||||||
aria-label={t("board.action.addContent")}
|
<Menu.Target>
|
||||||
onClick={() =>
|
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||||
openModal({
|
<Group gap={4} wrap="nowrap">
|
||||||
boardActions: {
|
<IconPlus stroke={1.5} />
|
||||||
widget: addWidget,
|
<IconChevronDown color="gray" size={16} />
|
||||||
app: addApp,
|
</Group>
|
||||||
integration: canConnectService ? connectService : undefined,
|
</HeaderButton>
|
||||||
container: addContainer,
|
</Menu.Target>
|
||||||
},
|
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||||
})
|
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||||
}
|
{t("item.action.create")}
|
||||||
>
|
</Menu.Item>
|
||||||
<IconPlus stroke={1.5} />
|
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||||
</HeaderButton>
|
{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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -5,7 +5,6 @@ import dynamic from "next/dynamic";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import {
|
import {
|
||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
IconLayoutBoard,
|
IconLayoutBoard,
|
||||||
@@ -99,9 +98,6 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
|||||||
|
|
||||||
latestBoardRef.current = board;
|
latestBoardRef.current = board;
|
||||||
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
||||||
onMutate(boardToSave) {
|
|
||||||
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
|
|
||||||
},
|
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
title: t("notification.success.title"),
|
title: t("notification.success.title"),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
|
||||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
@@ -41,12 +41,12 @@ interface AppsPageProps {
|
|||||||
export default async function AppsPage(props: AppsPageProps) {
|
export default async function AppsPage(props: AppsPageProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) redirect("/auth/login");
|
if (!session) redirect("/auth/login");
|
||||||
|
if (!session.user.permissions.includes("app-modify-all")) notFound();
|
||||||
|
|
||||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||||
const t = await getScopedI18n("app");
|
const t = await getScopedI18n("app");
|
||||||
const canCreate = session.user.permissions.includes("app-create");
|
const canCreate = session.user.permissions.includes("app-create");
|
||||||
const canModify = session.user.permissions.includes("app-modify-all");
|
|
||||||
const canDelete = session.user.permissions.includes("app-full-all");
|
const canDelete = session.user.permissions.includes("app-full-all");
|
||||||
const hasSearch = Boolean(searchParams.search?.trim());
|
const hasSearch = Boolean(searchParams.search?.trim());
|
||||||
|
|
||||||
@@ -100,7 +100,6 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
<AppItem
|
<AppItem
|
||||||
key={app.id}
|
key={app.id}
|
||||||
app={app}
|
app={app}
|
||||||
canModify={canModify}
|
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
editLabel={t("page.list.action.edit", { name: app.name })}
|
editLabel={t("page.list.action.edit", { name: app.name })}
|
||||||
/>
|
/>
|
||||||
@@ -113,12 +112,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
|||||||
|
|
||||||
interface AppItemProps {
|
interface AppItemProps {
|
||||||
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
app: RouterOutputs["app"]["getPaginated"]["items"][number];
|
||||||
canModify: boolean;
|
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
editLabel: string;
|
editLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
const AppItem = ({ app, canDelete, editLabel }: AppItemProps) => {
|
||||||
const descriptionLines = app.description?.split("\n");
|
const descriptionLines = app.description?.split("\n");
|
||||||
const safeHref = getSafeAppHref(app.href);
|
const safeHref = getSafeAppHref(app.href);
|
||||||
|
|
||||||
@@ -157,23 +155,19 @@ const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
canModify || canDelete ? (
|
<ActionIconGroup>
|
||||||
<ActionIconGroup>
|
<ActionIcon
|
||||||
{canModify && (
|
component={Link}
|
||||||
<ActionIcon
|
href={`/manage/apps/edit/${app.id}`}
|
||||||
component={Link}
|
variant="subtle"
|
||||||
href={`/manage/apps/edit/${app.id}`}
|
color="gray"
|
||||||
variant="subtle"
|
size={44}
|
||||||
color="gray"
|
aria-label={editLabel}
|
||||||
size={44}
|
>
|
||||||
aria-label={editLabel}
|
<IconPencil size={18} stroke={1.5} />
|
||||||
>
|
</ActionIcon>
|
||||||
<IconPencil size={18} stroke={1.5} />
|
{canDelete && <AppDeleteButton app={app} />}
|
||||||
</ActionIcon>
|
</ActionIconGroup>
|
||||||
)}
|
|
||||||
{canDelete && <AppDeleteButton app={app} />}
|
|
||||||
</ActionIconGroup>
|
|
||||||
) : undefined
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,12 +54,7 @@ export default async function ManageBoardsPage() {
|
|||||||
floatingPrimaryAction={canCreateBoards}
|
floatingPrimaryAction={canCreateBoards}
|
||||||
>
|
>
|
||||||
{boards.length === 0 && (
|
{boards.length === 0 && (
|
||||||
<NoResults
|
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||||
icon={IconLayoutDashboard}
|
|
||||||
title={t("noResults.title")}
|
|
||||||
description={t("noResults.description")}
|
|
||||||
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{boards.length > 0 && (
|
{boards.length > 0 && (
|
||||||
<TourTarget id="manage-boards-list">
|
<TourTarget id="manage-boards-list">
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import {
|
|||||||
IconCopy,
|
IconCopy,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconPlayerPause,
|
|
||||||
IconPlayerPlay,
|
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
@@ -23,10 +21,9 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
|||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
|
||||||
|
|
||||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||||
import { downloadJson } from "~/components/custom-widgets/download";
|
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
|
||||||
|
|
||||||
const iconProps = { size: 16, stroke: 1.5 };
|
const iconProps = { size: 16, stroke: 1.5 };
|
||||||
|
|
||||||
@@ -43,11 +40,12 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
const [publishOpened, publishControls] = useDisclosure(false);
|
||||||
const [migrationOpened, migrationControls] = useDisclosure(false);
|
const [migrationOpened, migrationControls] = useDisclosure(false);
|
||||||
const migrationFileInputRef = useRef<HTMLInputElement>(null);
|
const migrationFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||||
|
|
||||||
const copyMigrationPrompt = async () => {
|
const copyMigrationPrompt = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
|
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
|
||||||
@@ -64,41 +62,30 @@ 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 queueMigratedWidget = (text: string) => {
|
||||||
const result = parseCustomWidgetClipboardDetailed(text);
|
const result = parseCustomWidgetClipboardDetailed(text);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
showErrorNotification({ title: t("action.migrate"), message: formatCustomWidgetImportIssues(result.issues) });
|
showErrorNotification({
|
||||||
|
title: t("action.migrate"),
|
||||||
|
message: formatCustomWidgetImportIssues(result.issues),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMigratedWidget(result.widget);
|
setMigratedWidget(result.widget);
|
||||||
migrationControls.open();
|
migrationControls.open();
|
||||||
};
|
};
|
||||||
const notifyMigrationError = () =>
|
|
||||||
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
|
||||||
|
|
||||||
const pasteMigratedWidget = async () => {
|
const pasteMigratedWidget = async () => {
|
||||||
try {
|
try {
|
||||||
queueMigratedWidget(await navigator.clipboard.readText());
|
queueMigratedWidget(await navigator.clipboard.readText());
|
||||||
} catch {
|
} catch {
|
||||||
notifyMigrationError();
|
showErrorNotification({
|
||||||
|
title: t("action.migrate"),
|
||||||
|
message: t("notification.migrationError"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -107,10 +94,15 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
try {
|
try {
|
||||||
queueMigratedWidget(loadEvent.target?.result as string);
|
queueMigratedWidget(loadEvent.target?.result as string);
|
||||||
} catch {
|
} catch {
|
||||||
notifyMigrationError();
|
showErrorNotification({
|
||||||
|
title: t("action.migrate"),
|
||||||
|
message: t("notification.migrationError"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
reader.addEventListener("error", notifyMigrationError);
|
reader.addEventListener("error", () => {
|
||||||
|
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
|
||||||
|
});
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
};
|
};
|
||||||
@@ -118,41 +110,18 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await utils.customWidget.export.fetch({ id: widget.id });
|
const data = await utils.customWidget.export.fetch({ id: widget.id });
|
||||||
downloadJson(data, `${widget.name}.json`);
|
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);
|
||||||
} catch {
|
} catch {
|
||||||
showErrorNotification({ title: t("action.export"), message: t("notification.exportError") });
|
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 = () => {
|
const handleDuplicate = () => {
|
||||||
duplicateMutation.mutate(
|
duplicateMutation.mutate(
|
||||||
{ id: widget.id },
|
{ id: widget.id },
|
||||||
@@ -177,24 +146,22 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
title: t("action.delete"),
|
title: t("action.delete"),
|
||||||
children: t("action.deleteConfirm", { name: widget.name }),
|
children: t("action.deleteConfirm", { name: widget.name }),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
deleteMutation.mutate(
|
const callbacks = {
|
||||||
{ id: widget.id },
|
onSuccess: () => {
|
||||||
{
|
showSuccessNotification({
|
||||||
onSuccess: () => {
|
title: t("action.delete"),
|
||||||
showSuccessNotification({
|
message: t("notification.deleted", { name: widget.name }),
|
||||||
title: t("action.delete"),
|
});
|
||||||
message: t("notification.deleted", { name: widget.name }),
|
void utils.customWidget.list.invalidate();
|
||||||
});
|
void utils.customWidget.available.invalidate();
|
||||||
void utils.customWidget.list.invalidate();
|
void utils.widget.customApi.getData.invalidate();
|
||||||
void utils.customWidget.available.invalidate();
|
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||||
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -210,14 +177,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{widget.valid && (
|
{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
|
<Menu.Item
|
||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
leftSection={<IconCopy {...iconProps} />}
|
leftSection={<IconCopy {...iconProps} />}
|
||||||
@@ -228,11 +187,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||||
{t("action.export")}
|
{t("action.export")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
|
||||||
component={Link}
|
|
||||||
href={`/manage/custom-widgets/publish/${widget.id}`}
|
|
||||||
leftSection={<IconBuildingStore {...iconProps} />}
|
|
||||||
>
|
|
||||||
{t("action.publishWorkshop")}
|
{t("action.publishWorkshop")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
@@ -240,13 +195,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
)}
|
)}
|
||||||
{widget.migrationRequired && (
|
{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} />}>
|
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
|
||||||
{t("action.copyMigrationPrompt")}
|
{t("action.copyMigrationPrompt")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -274,6 +222,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
|||||||
)}
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
|
||||||
{widget.migrationRequired && (
|
{widget.migrationRequired && (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -19,18 +19,12 @@ describe("legacy custom widget migration UI", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("replaces the legacy record through migration and refreshes every consumer", () => {
|
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");
|
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
|
||||||
|
|
||||||
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
|
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
|
||||||
expect(importer).toMatch(
|
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
|
||||||
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
|
expect(dialog).toContain("utils.customWidget.list.invalidate()");
|
||||||
);
|
expect(dialog).toContain("utils.customWidget.available.invalidate()");
|
||||||
expect(importer).toContain("utils.customWidget.list.invalidate()");
|
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
|
||||||
expect(importer).toContain("utils.customWidget.available.invalidate()");
|
|
||||||
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
|
|
||||||
expect(dialog).toContain("useCustomWidgetImport({");
|
|
||||||
expect(dialog).toContain("legacyId,");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
|
|
||||||
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
|
|
||||||
import { NoResults } from "~/components/no-results";
|
import { NoResults } from "~/components/no-results";
|
||||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||||
|
|
||||||
@@ -31,164 +31,173 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageCollectionList ariaLabel={t("page.list.title")}>
|
<Stack gap="sm">
|
||||||
{definitions.map((definition) => (
|
{definitions.map((def) => (
|
||||||
<CustomWidgetRow key={definition.id} widget={definition} />
|
<CustomWidgetCard key={def.id} widget={def} />
|
||||||
))}
|
))}
|
||||||
</ManageCollectionList>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||||
|
|
||||||
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||||
const t = useScopedI18n("customWidget");
|
const t = useScopedI18n("customWidget");
|
||||||
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
|
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||||
const origins = [...new Set(widget.sources.map((source) => source.origin))];
|
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"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Card
|
||||||
<ManageCollectionItem
|
padding="sm"
|
||||||
leading={
|
withBorder={!widget.valid}
|
||||||
<Avatar
|
bd={
|
||||||
size={40}
|
widget.migrationRequired
|
||||||
radius="sm"
|
? "1px solid var(--mantine-color-yellow-6)"
|
||||||
src={widget.iconUrl}
|
: !widget.valid
|
||||||
color={widget.valid ? "pink" : "red"}
|
? "1px solid var(--mantine-color-red-6)"
|
||||||
styles={{ image: { objectFit: "contain" } }}
|
: undefined
|
||||||
alt=""
|
}
|
||||||
>
|
style={{
|
||||||
<IconApi size={20} stroke={1.5} />
|
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
|
||||||
</Avatar>
|
transition: "opacity 150ms ease",
|
||||||
}
|
}}
|
||||||
title={
|
>
|
||||||
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
|
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||||
{widget.name}
|
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||||
</Text>
|
{widget.iconUrl ? (
|
||||||
}
|
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||||
badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />}
|
) : (
|
||||||
description={
|
<Avatar size={36} radius="sm" color="red">
|
||||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
<IconApi size={18} />
|
||||||
{widget.description || t("page.list.noDescription")}
|
</Avatar>
|
||||||
</Text>
|
)}
|
||||||
}
|
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||||
metadata={
|
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||||
<Group gap={6} wrap="wrap">
|
{widget.name}
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("page.list.requestCount", { count: widget.requestCount })}
|
|
||||||
</Text>
|
</Text>
|
||||||
{origins.map((origin) => (
|
{widget.migrationRequired ? (
|
||||||
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
|
<Text size="xs" c="yellow.8">
|
||||||
· {origin}
|
{t("page.list.migrationDescription")}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
) : !widget.valid ? (
|
||||||
</Group>
|
<Text size="xs" c="red">
|
||||||
}
|
{t("page.list.invalidDefinitionDescription")}
|
||||||
actions={
|
</Text>
|
||||||
<Group gap="xs" wrap="nowrap">
|
) : null}
|
||||||
{widget.valid && (
|
{widget.sources[0] && (
|
||||||
<Button
|
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||||
|
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{widget.migrationRequired ? (
|
||||||
|
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||||
|
<Badge
|
||||||
|
color="yellow"
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconSparkles size={12} />}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{t("page.list.migrationRequired")}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : widget.valid ? (
|
||||||
|
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||||
|
JSX · {widget.requestCount}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
label={widget.validationIssues
|
||||||
|
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||||
|
.join("\n")}
|
||||||
|
multiline
|
||||||
|
maw={520}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconAlertTriangle size={12} />}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{t("page.list.invalidDefinition")}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{widget.missingSecrets.length > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||||
|
multiline
|
||||||
|
maw={320}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
color="yellow"
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconAlertTriangle size={12} />}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
|
{widget.valid && (
|
||||||
|
<>
|
||||||
|
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={widget.enabled}
|
||||||
|
onChange={handleToggle}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<ActionIcon
|
||||||
component={Link}
|
component={Link}
|
||||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="sm"
|
color="gray"
|
||||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
aria-label={t("action.edit")}
|
||||||
>
|
>
|
||||||
{t("action.edit")}
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
)}
|
</>
|
||||||
<CustomWidgetRowActions
|
)}
|
||||||
widget={{
|
<CustomWidgetRowActions
|
||||||
id: widget.id,
|
widget={{
|
||||||
name: widget.name,
|
id: widget.id,
|
||||||
enabled: widget.enabled,
|
name: widget.name,
|
||||||
valid: widget.valid,
|
enabled: widget.enabled,
|
||||||
migrationRequired: widget.migrationRequired,
|
valid: widget.valid,
|
||||||
}}
|
migrationRequired: widget.migrationRequired,
|
||||||
/>
|
}}
|
||||||
</Group>
|
/>
|
||||||
}
|
</Group>
|
||||||
actionsAlignment="center"
|
</Group>
|
||||||
/>
|
</Card>
|
||||||
{widget.valid && (
|
|
||||||
<CustomWidgetSourceSetupDialog
|
|
||||||
definitionId={widget.id}
|
|
||||||
opened={sourceSetupOpened}
|
|
||||||
onClose={sourceSetupControls.close}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything that decides whether this widget can actually run right now. */
|
|
||||||
function CustomWidgetStatusBadges({
|
|
||||||
widget,
|
|
||||||
onConfigureSources,
|
|
||||||
}: {
|
|
||||||
widget: WidgetDef;
|
|
||||||
onConfigureSources(): void;
|
|
||||||
}) {
|
|
||||||
const t = useScopedI18n("customWidget");
|
|
||||||
|
|
||||||
if (widget.migrationRequired) {
|
|
||||||
return (
|
|
||||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
|
||||||
<Badge color="yellow" size="sm" variant="light" leftSection={<IconSparkles size={12} />}>
|
|
||||||
{t("page.list.migrationRequired")}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!widget.valid) {
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={widget.validationIssues
|
|
||||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
|
||||||
.join("\n")}
|
|
||||||
multiline
|
|
||||||
maw={520}
|
|
||||||
>
|
|
||||||
<Badge color="red" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
|
|
||||||
{t("page.list.invalidDefinition")}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
{widget.enabled ? (
|
|
||||||
<Badge color="green" size="sm" variant="light">
|
|
||||||
{t("page.list.enabled")}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Tooltip label={t("page.list.disabledDescription")}>
|
|
||||||
<Badge color="gray" size="sm" variant="light">
|
|
||||||
{t("page.list.disabled")}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{widget.missingSecrets.length > 0 && (
|
|
||||||
<Tooltip
|
|
||||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
|
||||||
multiline
|
|
||||||
maw={320}
|
|
||||||
>
|
|
||||||
<UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}>
|
|
||||||
<Badge
|
|
||||||
color="yellow"
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconAlertTriangle size={12} />}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
|
||||||
</Badge>
|
|
||||||
</UnstyledButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"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 { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||||
import { CustomWidgetList } from "./_custom-widget-list";
|
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||||
import { CustomWidgetTabs } from "./_custom-widget-tabs";
|
|
||||||
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
|
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
|
||||||
|
import { CustomWidgetList } from "./_custom-widget-list";
|
||||||
|
|
||||||
export default async function CustomWidgetsPage() {
|
export default async function CustomWidgetsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -38,13 +38,13 @@ export default async function CustomWidgetsPage() {
|
|||||||
>
|
>
|
||||||
{t("action.downloadSkill")}
|
{t("action.downloadSkill")}
|
||||||
</MobileAffixButton>
|
</MobileAffixButton>
|
||||||
|
<WorkshopInstallButton />
|
||||||
<ImportCustomWidgetButton />
|
<ImportCustomWidgetButton />
|
||||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||||
{t("action.create")}
|
{t("action.create")}
|
||||||
</MobileAffixButton>
|
</MobileAffixButton>
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
toolbar={<CustomWidgetTabs active="installed" />}
|
|
||||||
>
|
>
|
||||||
<CustomWidgetBetaBanner />
|
<CustomWidgetBetaBanner />
|
||||||
<CustomWidgetList definitions={definitions} />
|
<CustomWidgetList definitions={definitions} />
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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 }} />;
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
"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);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"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") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
|
||||||
|
|
||||||
import { WorkshopBrowse } from "./_workshop-browse";
|
|
||||||
|
|
||||||
const searchParamsSchema = z.object({
|
|
||||||
search: z.string().optional(),
|
|
||||||
sort: z.enum(["top", "newest"]).catch("top"),
|
|
||||||
page: z
|
|
||||||
.string()
|
|
||||||
.regex(/^[1-9]\d*$/u)
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.number().int().positive())
|
|
||||||
.catch(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface WorkshopBrowsePageProps {
|
|
||||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function WorkshopBrowsePage(props: WorkshopBrowsePageProps) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
|
||||||
|
|
||||||
const { search, sort, page } = searchParamsSchema.parse(await props.searchParams);
|
|
||||||
|
|
||||||
return <WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} />;
|
|
||||||
}
|
|
||||||
@@ -42,11 +42,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
|||||||
<Text size="md">
|
<Text size="md">
|
||||||
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
|
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
|
||||||
statusCode: error.data.statusCode.toString(),
|
statusCode: error.data.statusCode.toString(),
|
||||||
url: () => (
|
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
|
||||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{error.data.url}
|
|
||||||
</Anchor>
|
|
||||||
),
|
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
@@ -54,11 +50,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
|||||||
{t.rich("integration.testConnection.error.statusCode.description", {
|
{t.rich("integration.testConnection.error.statusCode.description", {
|
||||||
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
|
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
|
||||||
statusCode: error.data.statusCode.toString(),
|
statusCode: error.data.statusCode.toString(),
|
||||||
url: () => (
|
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
|
||||||
<Anchor href={error.data.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{error.data.url}
|
|
||||||
</Anchor>
|
|
||||||
),
|
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@@ -74,7 +66,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
|||||||
{t("integration.testConnection.error.common.cause.title")}
|
{t("integration.testConnection.error.common.cause.title")}
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
<pre style={{ whiteSpace: "pre-wrap" }}>
|
||||||
{error.name}: {error.message}
|
{error.name}: {error.message}
|
||||||
{"\n"}
|
{"\n"}
|
||||||
{causeArray
|
{causeArray
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
|
||||||
type IntegrationCreateErrorResult = Extract<RouterOutputs["integration"]["create"], { error: unknown }>;
|
export type AnyMappedTestConnectionError = Exclude<RouterOutputs["integration"]["create"], undefined>["error"];
|
||||||
|
|
||||||
export type AnyMappedTestConnectionError = IntegrationCreateErrorResult["error"];
|
|
||||||
export type MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
|
export type MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
|
||||||
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
|
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
|
||||||
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;
|
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useImperativeHandle, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
||||||
@@ -25,16 +25,8 @@ import { SecretKindsSegmentedControl } from "../../_components/secrets/integrati
|
|||||||
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
|
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
|
||||||
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
|
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
|
||||||
|
|
||||||
interface EditIntegrationFormProps {
|
interface EditIntegrationForm {
|
||||||
integration: RouterOutputs["integration"]["byId"];
|
integration: RouterOutputs["integration"]["byId"];
|
||||||
hideButtons?: boolean;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
formRef?: React.Ref<EditIntegrationFormHandle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditIntegrationFormHandle {
|
|
||||||
submit: () => Promise<boolean>;
|
|
||||||
isDirty: () => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||||
@@ -50,12 +42,7 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const EditIntegrationForm = ({
|
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||||
integration,
|
|
||||||
hideButtons = false,
|
|
||||||
onSuccess,
|
|
||||||
formRef,
|
|
||||||
}: EditIntegrationFormProps) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
const allSecretKinds = getAllSecretKindOptions(integration.kind);
|
||||||
@@ -80,36 +67,17 @@ export const EditIntegrationForm = ({
|
|||||||
app: integration.app ?? null,
|
app: integration.app ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation({
|
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("integration.page.edit.notification.error.title"),
|
|
||||||
message: t("integration.page.edit.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||||
const errorRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||||
|
|
||||||
const handleSubmitAsync = useCallback(
|
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
|
||||||
async ({ app, ...values }: FormType) => {
|
const url = hasUrlSecret
|
||||||
setError(null);
|
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||||
let url: string;
|
: values.url;
|
||||||
try {
|
await mutateAsync(
|
||||||
url = hasUrlSecret
|
{
|
||||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
|
||||||
: values.url;
|
|
||||||
} catch {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("integration.page.edit.notification.error.title"),
|
|
||||||
message: t("integration.page.edit.notification.error.message"),
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await mutateAsync({
|
|
||||||
id: integration.id,
|
id: integration.id,
|
||||||
...values,
|
...values,
|
||||||
url,
|
url,
|
||||||
@@ -118,130 +86,112 @@ export const EditIntegrationForm = ({
|
|||||||
value: secret.value === "" ? null : secret.value,
|
value: secret.value === "" ? null : secret.value,
|
||||||
})),
|
})),
|
||||||
appId: app?.id ?? null,
|
appId: app?.id ?? null,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// We do it this way as we are unable to send a typesafe error through onError
|
||||||
|
if (data?.error) {
|
||||||
|
setError(data.error);
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("integration.page.edit.notification.error.title"),
|
||||||
|
message: t("integration.page.edit.notification.error.message"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// We do it this way as we are unable to send a typesafe error through onError
|
showSuccessNotification({
|
||||||
if (data?.error) {
|
title: t("integration.page.edit.notification.success.title"),
|
||||||
setError(data.error);
|
message: t("integration.page.edit.notification.success.message"),
|
||||||
showErrorNotification({
|
});
|
||||||
title: t("integration.page.edit.notification.error.title"),
|
void utils.integration.invalidate();
|
||||||
message: t("integration.page.edit.notification.error.message"),
|
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||||
});
|
},
|
||||||
requestAnimationFrame(() => errorRef.current?.focus());
|
onError: () => {
|
||||||
return false;
|
showErrorNotification({
|
||||||
}
|
title: t("integration.page.edit.notification.error.title"),
|
||||||
|
message: t("integration.page.edit.notification.error.message"),
|
||||||
showSuccessNotification({
|
});
|
||||||
title: t("integration.page.edit.notification.success.title"),
|
},
|
||||||
message: t("integration.page.edit.notification.success.message"),
|
},
|
||||||
});
|
);
|
||||||
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
|
};
|
||||||
onSuccess?.();
|
|
||||||
if (!hideButtons) {
|
|
||||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
|
|
||||||
);
|
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
formRef,
|
|
||||||
() => ({
|
|
||||||
submit: () =>
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
form.onSubmit(
|
|
||||||
async (values) => {
|
|
||||||
try {
|
|
||||||
resolve(await handleSubmitAsync(values));
|
|
||||||
} catch {
|
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
() => resolve(false),
|
|
||||||
)();
|
|
||||||
}),
|
|
||||||
isDirty: () => form.isDirty(),
|
|
||||||
}),
|
|
||||||
[form, handleSubmitAsync],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isInitialSecretKinds =
|
const isInitialSecretKinds =
|
||||||
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
|
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
|
||||||
form.values.secrets.length === initialSecretsKinds.length;
|
form.values.secrets.length === initialSecretsKinds.length;
|
||||||
|
|
||||||
const formFields = (
|
return (
|
||||||
<Stack>
|
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
<Stack>
|
||||||
|
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||||
|
|
||||||
{hasUrlSecret ? null : (
|
{hasUrlSecret ? null : (
|
||||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Fieldset legend={t("integration.secrets.title")}>
|
<Fieldset legend={t("integration.secrets.title")}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{allSecretKinds.length > 1 && (
|
{allSecretKinds.length > 1 && (
|
||||||
<SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
|
<SecretKindsSegmentedControl
|
||||||
)}
|
defaultKinds={initialSecretsKinds}
|
||||||
{!isInitialSecretKinds
|
secretKinds={allSecretKinds}
|
||||||
? null
|
form={form}
|
||||||
: form.values.secrets.map((secret, index) => (
|
/>
|
||||||
<SecretCard
|
)}
|
||||||
key={secret.kind}
|
{!isInitialSecretKinds
|
||||||
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
? null
|
||||||
onCancel={() =>
|
: form.values.secrets.map((secret, index) => (
|
||||||
new Promise((resolve) => {
|
<SecretCard
|
||||||
// When nothing changed, just close the secret card
|
|
||||||
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
|
||||||
return resolve(true);
|
|
||||||
}
|
|
||||||
openConfirmModal({
|
|
||||||
title: t("integration.secrets.reset.title"),
|
|
||||||
children: t("integration.secrets.reset.message"),
|
|
||||||
onCancel: () => resolve(false),
|
|
||||||
onConfirm: () => {
|
|
||||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IntegrationSecretInput
|
|
||||||
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
|
||||||
key={secret.kind}
|
key={secret.kind}
|
||||||
kind={secret.kind}
|
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
|
||||||
|
onCancel={() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
// When nothing changed, just close the secret card
|
||||||
|
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
|
||||||
|
return resolve(true);
|
||||||
|
}
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("integration.secrets.reset.title"),
|
||||||
|
children: t("integration.secrets.reset.message"),
|
||||||
|
onCancel: () => resolve(false),
|
||||||
|
onConfirm: () => {
|
||||||
|
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IntegrationSecretInput
|
||||||
|
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
|
||||||
|
key={secret.kind}
|
||||||
|
kind={secret.kind}
|
||||||
|
{...form.getInputProps(`secrets.${index}.value`)}
|
||||||
|
/>
|
||||||
|
</SecretCard>
|
||||||
|
))}
|
||||||
|
{isInitialSecretKinds
|
||||||
|
? null
|
||||||
|
: form.values.secrets.map(({ kind }, index) => (
|
||||||
|
<IntegrationSecretInput
|
||||||
|
withAsterisk
|
||||||
|
key={kind}
|
||||||
|
kind={kind}
|
||||||
{...form.getInputProps(`secrets.${index}.value`)}
|
{...form.getInputProps(`secrets.${index}.value`)}
|
||||||
/>
|
/>
|
||||||
</SecretCard>
|
))}
|
||||||
))}
|
{form.values.secrets.length === 0 && (
|
||||||
{isInitialSecretKinds
|
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||||
? null
|
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||||
: form.values.secrets.map(({ kind }, index) => (
|
</Alert>
|
||||||
<IntegrationSecretInput
|
)}
|
||||||
withAsterisk
|
</Stack>
|
||||||
key={kind}
|
</Fieldset>
|
||||||
kind={kind}
|
|
||||||
{...form.getInputProps(`secrets.${index}.value`)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{form.values.secrets.length === 0 && (
|
|
||||||
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
|
||||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Fieldset>
|
|
||||||
|
|
||||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||||
|
|
||||||
{error !== null && (
|
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||||
<div ref={errorRef} role="alert" tabIndex={-1}>
|
|
||||||
<IntegrationTestConnectionError error={error} url={form.values.url} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hideButtons && (
|
|
||||||
<Group justify="end" align="center">
|
<Group justify="end" align="center">
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
{t("common.action.backToOverview")}
|
{t("common.action.backToOverview")}
|
||||||
@@ -250,15 +200,9 @@ export const EditIntegrationForm = ({
|
|||||||
{t("integration.testConnection.action.edit")}
|
{t("integration.testConnection.action.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
</Stack>
|
||||||
</Stack>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hideButtons) {
|
|
||||||
return formFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormType = z.infer<typeof formSchema>;
|
type FormType = z.infer<typeof formSchema>;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
|
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
@@ -50,12 +49,10 @@ interface NewIntegrationFormProps {
|
|||||||
kind: IntegrationKind;
|
kind: IntegrationKind;
|
||||||
initialUrl?: string;
|
initialUrl?: string;
|
||||||
initialName?: string;
|
initialName?: string;
|
||||||
onSuccess: (result?: CreatedIntegrationResult) => void;
|
onSuccess: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreatedIntegrationResult = Extract<RouterOutputs["integration"]["create"], { integration: unknown }>;
|
|
||||||
|
|
||||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||||
z.object({
|
z.object({
|
||||||
hasApp: z.boolean(),
|
hasApp: z.boolean(),
|
||||||
@@ -70,18 +67,12 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
|||||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||||
const validationSchema = canCreateApps
|
|
||||||
? formSchema
|
|
||||||
: formSchema.superRefine((values, context) => {
|
|
||||||
if (!values.hasApp || values.appId !== null) return;
|
|
||||||
context.addIssue({ code: "custom", message: t("common.zod.errors.required"), path: ["appId"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
|
||||||
if (hasUrlSecret) {
|
if (hasUrlSecret) {
|
||||||
url = "http://localhost";
|
url = "http://localhost";
|
||||||
}
|
}
|
||||||
const form = useZodForm(validationSchema, {
|
const form = useZodForm(formSchema, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: initialName ?? getIntegrationName(kind),
|
name: initialName ?? getIntegrationName(kind),
|
||||||
url,
|
url,
|
||||||
@@ -90,7 +81,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
|||||||
value: "",
|
value: "",
|
||||||
})),
|
})),
|
||||||
attemptSearchEngineCreation: true,
|
attemptSearchEngineCreation: true,
|
||||||
hasApp: canCreateApps,
|
hasApp: true,
|
||||||
appHref: url,
|
appHref: url,
|
||||||
appId: null,
|
appId: null,
|
||||||
},
|
},
|
||||||
@@ -110,9 +101,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
|||||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||||
: values.url;
|
: values.url;
|
||||||
|
|
||||||
const onMutationSuccess = (
|
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
|
||||||
data: CreatedIntegrationResult | { error?: AnyMappedTestConnectionError } | undefined | void,
|
|
||||||
) => {
|
|
||||||
if (data && "error" in data && data.error) {
|
if (data && "error" in data && data.error) {
|
||||||
setError(data.error);
|
setError(data.error);
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -127,7 +116,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
|||||||
message: t("integration.page.create.notification.success.message"),
|
message: t("integration.page.create.notification.success.message"),
|
||||||
});
|
});
|
||||||
|
|
||||||
onSuccess(data && "integration" in data ? data : undefined);
|
onSuccess();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMutationError = () => {
|
const onMutationError = () => {
|
||||||
@@ -142,15 +131,13 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o
|
|||||||
const app = hasApp
|
const app = hasApp
|
||||||
? appId !== null
|
? appId !== null
|
||||||
? { id: appId }
|
? { id: appId }
|
||||||
: canCreateApps
|
: {
|
||||||
? {
|
name: values.name,
|
||||||
name: values.name,
|
href: hasCustomHref ? appHref : url,
|
||||||
href: hasCustomHref ? appHref : url,
|
iconUrl: getIconUrl(kind),
|
||||||
iconUrl: getIconUrl(kind),
|
description: null,
|
||||||
description: null,
|
pingUrl: url,
|
||||||
pingUrl: url,
|
}
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await createIntegrationAsync(
|
await createIntegrationAsync(
|
||||||
@@ -242,10 +229,8 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
|
|||||||
checkboxInputProps.onChange(event);
|
checkboxInputProps.onChange(event);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
label={t(canCreateApps ? "integration.field.createApp.label" : "integration.field.linkApp.label")}
|
label={t("integration.field.createApp.label")}
|
||||||
description={t(
|
description={t("integration.field.createApp.description")}
|
||||||
canCreateApps ? "integration.field.createApp.description" : "integration.field.linkApp.description",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Collapse expanded={form.values.hasApp}>
|
<Collapse expanded={form.values.hasApp}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Badge, Stack, Text } from "@mantine/core";
|
||||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -32,8 +32,11 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
const canCreate = session.user.permissions.includes("integration-create");
|
const canCreate = session.user.permissions.includes("integration-create");
|
||||||
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
|
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
|
||||||
|
const canSee = hasGlobalFullAccess || integrations.some((integration) => integration.permissions.hasFullAccess);
|
||||||
|
|
||||||
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
|
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
|
||||||
const filteredIntegrations = integrations
|
const filteredIntegrations = integrations
|
||||||
|
.filter((integration) => hasGlobalFullAccess || integration.permissions.hasFullAccess)
|
||||||
.filter((integration) => {
|
.filter((integration) => {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
|
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
|
||||||
@@ -53,7 +56,13 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
ariaLabel={t("page.list.ariaLabel")}
|
ariaLabel={t("page.list.ariaLabel")}
|
||||||
itemCount={filteredIntegrations.length}
|
itemCount={filteredIntegrations.length}
|
||||||
emptyState={
|
emptyState={
|
||||||
hasSearch ? (
|
!canSee ? (
|
||||||
|
<NoResults
|
||||||
|
icon={IconPlugX}
|
||||||
|
title={t("page.list.noResults.noPermissionTitle")}
|
||||||
|
description={t("page.list.noResults.noPermissionDescription")}
|
||||||
|
/>
|
||||||
|
) : hasSearch ? (
|
||||||
<NoResults
|
<NoResults
|
||||||
icon={IconPlugX}
|
icon={IconPlugX}
|
||||||
title={t("page.list.noResults.filteredTitle")}
|
title={t("page.list.noResults.filteredTitle")}
|
||||||
@@ -93,24 +102,33 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
floatingPrimaryAction={canCreate}
|
floatingPrimaryAction={canCreate}
|
||||||
>
|
>
|
||||||
{filteredIntegrations.map((integration) => (
|
{filteredIntegrations.map((integration) => (
|
||||||
<IntegrationItem
|
<IntegrationItem key={integration.id} integration={integration} />
|
||||||
key={integration.id}
|
|
||||||
integration={integration}
|
|
||||||
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</ManageCollectionPage>
|
</ManageCollectionPage>
|
||||||
);
|
);
|
||||||
|
|
||||||
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
const pageContent =
|
||||||
|
filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
|
||||||
|
|
||||||
|
if (hasGlobalFullAccess) {
|
||||||
|
return pageContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("page.list.scopedNote")}
|
||||||
|
</Text>
|
||||||
|
{pageContent}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IntegrationItemProps {
|
interface IntegrationItemProps {
|
||||||
integration: RouterOutputs["integration"]["all"][number];
|
integration: RouterOutputs["integration"]["all"][number];
|
||||||
canManage: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
|
const IntegrationItem = async ({ integration }: IntegrationItemProps) => {
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
const kindName = getIntegrationName(integration.kind);
|
const kindName = getIntegrationName(integration.kind);
|
||||||
const safeUrl = getSafeApplicationUrl(integration.url);
|
const safeUrl = getSafeApplicationUrl(integration.url);
|
||||||
@@ -143,21 +161,19 @@ const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps)
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
canManage ? (
|
<ActionIconGroup>
|
||||||
<ActionIconGroup>
|
<ActionIcon
|
||||||
<ActionIcon
|
component={Link}
|
||||||
component={Link}
|
href={`/manage/integrations/edit/${integration.id}`}
|
||||||
href={`/manage/integrations/edit/${integration.id}`}
|
variant="subtle"
|
||||||
variant="subtle"
|
color="gray"
|
||||||
color="gray"
|
size={44}
|
||||||
size={44}
|
aria-label={t("page.list.action.edit", { name: integration.name })}
|
||||||
aria-label={t("page.list.action.edit", { name: integration.name })}
|
>
|
||||||
>
|
<IconPencil size={18} stroke={1.5} />
|
||||||
<IconPencil size={18} stroke={1.5} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
<DeleteIntegrationActionButton integration={integration} />
|
||||||
<DeleteIntegrationActionButton integration={integration} />
|
</ActionIconGroup>
|
||||||
</ActionIconGroup>
|
|
||||||
) : undefined
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IconApi,
|
IconApi,
|
||||||
IconBook2,
|
IconBook2,
|
||||||
IconBox,
|
IconBox,
|
||||||
|
IconBuildingStore,
|
||||||
IconBrandDiscord,
|
IconBrandDiscord,
|
||||||
IconBrandDocker,
|
IconBrandDocker,
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
@@ -65,6 +67,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
sessionPromise,
|
sessionPromise,
|
||||||
shouldRunManageTourPromise,
|
shouldRunManageTourPromise,
|
||||||
]);
|
]);
|
||||||
|
const hasGlobalIntegrationFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||||
|
const hasDelegatedIntegrationFullAccess =
|
||||||
|
!hasGlobalIntegrationFullAccess && session
|
||||||
|
? (await api.integration.all()).some((integration) => integration.permissions.hasFullAccess)
|
||||||
|
: false;
|
||||||
|
const canManageIntegrations = hasGlobalIntegrationFullAccess || hasDelegatedIntegrationFullAccess;
|
||||||
const navigationLinks: NavigationLink[] = [
|
const navigationLinks: NavigationLink[] = [
|
||||||
{
|
{
|
||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
@@ -82,7 +90,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
href: "/manage/apps",
|
href: "/manage/apps",
|
||||||
label: t("items.apps"),
|
label: t("items.apps"),
|
||||||
hidden: !session?.user.permissions.includes("app-create"),
|
hidden: !session?.user.permissions.includes("app-modify-all"),
|
||||||
iconProps: {
|
iconProps: {
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
@@ -92,7 +100,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
icon: IconAffiliateFilled,
|
icon: IconAffiliateFilled,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
hidden: !session?.user.permissions.includes("integration-create"),
|
hidden: !canManageIntegrations,
|
||||||
"data-onboarding-tour-id": "manage-integrations",
|
"data-onboarding-tour-id": "manage-integrations",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,6 +109,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.customWidgets"),
|
label: t("items.customWidgets"),
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: IconBuildingStore,
|
||||||
|
href: "/manage/workshop",
|
||||||
|
label: t("items.workshop"),
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: IconSearch,
|
icon: IconSearch,
|
||||||
href: "/manage/search-engines",
|
href: "/manage/search-engines",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
import { Anchor, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
export default async function NotFound() {
|
export default async function NotFound() {
|
||||||
const t = await getScopedI18n("management.notFound");
|
const t = await getScopedI18n("management.notFound");
|
||||||
@@ -9,6 +10,9 @@ export default async function NotFound() {
|
|||||||
<Stack align="center">
|
<Stack align="center">
|
||||||
<Title order={1}>{t("title")}</Title>
|
<Title order={1}>{t("title")}</Title>
|
||||||
<Text>{t("text")}</Text>
|
<Text>{t("text")}</Text>
|
||||||
|
<Anchor component={Link} href="/manage">
|
||||||
|
{t("backToHome")}
|
||||||
|
</Anchor>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ export default async function ManagementPage() {
|
|||||||
<Space h="md" />
|
<Space h="md" />
|
||||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
{statistics.map((statistic) => (
|
{statistics.map((statistic) => (
|
||||||
<Card component={Link} href={statistic.path} key={statistic.path}>
|
<Card
|
||||||
|
component={Link}
|
||||||
|
href={statistic.path}
|
||||||
|
key={statistic.path}
|
||||||
|
aria-label={`${statistic.count} ${t(`statistic.${statistic.titleKey}`)}`}
|
||||||
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
<Text size="xl" fw={700}>
|
<Text size="xl" fw={700}>
|
||||||
@@ -42,7 +47,7 @@ export default async function ManagementPage() {
|
|||||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" aria-hidden="true" />
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
dismissDockerReconciliationCandidate,
|
|
||||||
filterDockerReconciliationInbox,
|
|
||||||
getTemplateUrl,
|
|
||||||
} from "./docker-reconciliation-inbox";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
{ candidateKey: "home:new", state: "newRecognized" as const },
|
|
||||||
{ candidateKey: "home:new-app", state: "newApp" as const },
|
|
||||||
{ candidateKey: "remote:moved", state: "moved" as const },
|
|
||||||
{ candidateKey: "home:linked", state: "linked" as const },
|
|
||||||
{ candidateKey: "home:represented", state: "represented" as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("Docker reconciliation inbox", () => {
|
|
||||||
test("separates attention items from represented services", () => {
|
|
||||||
expect(
|
|
||||||
filterDockerReconciliationInbox(candidates, "attention", []).map(({ candidateKey }) => candidateKey),
|
|
||||||
).toEqual(["home:new", "home:new-app", "remote:moved"]);
|
|
||||||
expect(
|
|
||||||
filterDockerReconciliationInbox(candidates, "represented", []).map(({ candidateKey }) => candidateKey),
|
|
||||||
).toEqual(["home:linked", "home:represented"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dismisses only the endpoint-qualified candidate key and remains idempotent", () => {
|
|
||||||
const dismissed = dismissDockerReconciliationCandidate([], "home:new");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
filterDockerReconciliationInbox(candidates, "all", dismissed).map(({ candidateKey }) => candidateKey),
|
|
||||||
).not.toContain("home:new");
|
|
||||||
expect(filterDockerReconciliationInbox(candidates, "all", dismissed)).toContainEqual({
|
|
||||||
candidateKey: "remote:moved",
|
|
||||||
state: "moved",
|
|
||||||
});
|
|
||||||
expect(dismissDockerReconciliationCandidate(dismissed, "home:new")).toBe(dismissed);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("builds service URLs from the server origin and published ports", () => {
|
|
||||||
expect(
|
|
||||||
getTemplateUrl(
|
|
||||||
{
|
|
||||||
container: { name: "Custom service", ports: [{ PrivatePort: 3000, Type: "tcp" }] },
|
|
||||||
match: null,
|
|
||||||
},
|
|
||||||
"home.lan",
|
|
||||||
"hostPort",
|
|
||||||
),
|
|
||||||
).toBe("http://home.lan");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
getTemplateUrl(
|
|
||||||
{
|
|
||||||
container: {
|
|
||||||
name: "Sonarr",
|
|
||||||
ports: [
|
|
||||||
{ PrivatePort: 3000, PublicPort: 3000, Type: "tcp" },
|
|
||||||
{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
match: { kind: "sonarr" },
|
|
||||||
},
|
|
||||||
"home.lan",
|
|
||||||
"hostPort",
|
|
||||||
),
|
|
||||||
).toBe("http://home.lan:9999");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
getTemplateUrl(
|
|
||||||
{
|
|
||||||
container: { name: "Sonarr", ports: [{ PrivatePort: 8989, PublicPort: 9999, Type: "tcp" }] },
|
|
||||||
match: { kind: "sonarr" },
|
|
||||||
},
|
|
||||||
"",
|
|
||||||
"hostPort",
|
|
||||||
),
|
|
||||||
).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import type { DockerServiceMatch, DockerServicePort, UrlTemplateMode } from "@homarr/definitions";
|
|
||||||
import { buildAppUrl, buildIntegrationUrl, getIntegrationDefaultPort } from "@homarr/definitions";
|
|
||||||
|
|
||||||
export type DockerReconciliationInboxFilter = "attention" | "represented" | "all";
|
|
||||||
|
|
||||||
interface DockerReconciliationInboxCandidate {
|
|
||||||
candidateKey: string;
|
|
||||||
state: "newRecognized" | "newApp" | "represented" | "linked" | "moved";
|
|
||||||
}
|
|
||||||
|
|
||||||
const attentionStates = new Set<DockerReconciliationInboxCandidate["state"]>(["newRecognized", "newApp", "moved"]);
|
|
||||||
|
|
||||||
export const filterDockerReconciliationInbox = <Candidate extends DockerReconciliationInboxCandidate>(
|
|
||||||
candidates: Candidate[],
|
|
||||||
filter: DockerReconciliationInboxFilter,
|
|
||||||
dismissedCandidateKeys: string[],
|
|
||||||
) => {
|
|
||||||
const dismissed = new Set(dismissedCandidateKeys);
|
|
||||||
return candidates.filter((candidate) => {
|
|
||||||
if (dismissed.has(candidate.candidateKey)) return false;
|
|
||||||
if (filter === "attention") return attentionStates.has(candidate.state);
|
|
||||||
if (filter === "represented") return candidate.state === "represented" || candidate.state === "linked";
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dismissDockerReconciliationCandidate = (dismissedCandidateKeys: string[], candidateKey: string) =>
|
|
||||||
dismissedCandidateKeys.includes(candidateKey) ? dismissedCandidateKeys : [...dismissedCandidateKeys, candidateKey];
|
|
||||||
|
|
||||||
interface DockerTemplateCandidate {
|
|
||||||
container: {
|
|
||||||
name: string;
|
|
||||||
ports?: DockerServicePort[];
|
|
||||||
};
|
|
||||||
match: Pick<DockerServiceMatch, "kind"> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getTemplateUrl = (candidate: DockerTemplateCandidate, serverOrigin: string, urlMode: UrlTemplateMode) => {
|
|
||||||
if (!serverOrigin.trim()) return "";
|
|
||||||
|
|
||||||
const tcpPorts = candidate.container.ports?.filter(({ Type }) => Type.toLowerCase() === "tcp") ?? [];
|
|
||||||
let publishedPort = tcpPorts.find(({ PublicPort }) => PublicPort)?.PublicPort;
|
|
||||||
|
|
||||||
if (candidate.match) {
|
|
||||||
const defaultPort = getIntegrationDefaultPort(candidate.match.kind);
|
|
||||||
const preferredPort = tcpPorts.find(
|
|
||||||
({ PrivatePort, PublicPort }) => PrivatePort === defaultPort && PublicPort !== undefined,
|
|
||||||
);
|
|
||||||
publishedPort = preferredPort?.PublicPort ?? publishedPort;
|
|
||||||
return buildIntegrationUrl(candidate.match.kind, serverOrigin, urlMode, publishedPort);
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildAppUrl(candidate.container.name, serverOrigin, urlMode, publishedPort);
|
|
||||||
};
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
ActionIcon,
|
|
||||||
Alert,
|
|
||||||
Autocomplete,
|
|
||||||
Avatar,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Collapse,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
SegmentedControl,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useLocalStorage } from "@mantine/hooks";
|
|
||||||
import {
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconApps,
|
|
||||||
IconCheck,
|
|
||||||
IconChevronDown,
|
|
||||||
IconEye,
|
|
||||||
IconEyeOff,
|
|
||||||
IconBrandDocker,
|
|
||||||
IconMinus,
|
|
||||||
IconPlugConnected,
|
|
||||||
IconRefresh,
|
|
||||||
IconSettings,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import type { UrlTemplateMode } from "@homarr/definitions";
|
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
|
||||||
import { normalizeServiceUrl, ServiceUrlTemplate } from "@homarr/onboarding";
|
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import { Link } from "@homarr/ui";
|
|
||||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
|
||||||
import type { DockerReconciliationInboxFilter } from "./docker-reconciliation-inbox";
|
|
||||||
import {
|
|
||||||
dismissDockerReconciliationCandidate,
|
|
||||||
filterDockerReconciliationInbox,
|
|
||||||
getTemplateUrl,
|
|
||||||
} from "./docker-reconciliation-inbox";
|
|
||||||
|
|
||||||
type ReconciliationCandidate = RouterOutputs["docker"]["reconcileServices"]["candidates"][number];
|
|
||||||
type ServiceHealth = RouterOutputs["docker"]["getServiceHealth"]["services"][number];
|
|
||||||
|
|
||||||
export const DockerReconciliation = ({ defaultServerOrigin }: { defaultServerOrigin: string }) => {
|
|
||||||
const t = useScopedI18n("docker.reconciliation");
|
|
||||||
const tGlobal = useI18n();
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const panelId = useId();
|
|
||||||
const [isOpen, setIsOpen] = useLocalStorage({
|
|
||||||
key: "homarr-docker-reconciliation-open",
|
|
||||||
defaultValue: false,
|
|
||||||
});
|
|
||||||
const reconciliation = clientApi.docker.reconcileServices.useQuery();
|
|
||||||
const health = clientApi.docker.getServiceHealth.useQuery(undefined, { enabled: isOpen });
|
|
||||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
|
||||||
async onSuccess() {
|
|
||||||
await Promise.all([
|
|
||||||
utils.docker.getContainers.invalidate(),
|
|
||||||
utils.docker.reconcileServices.invalidate(),
|
|
||||||
utils.docker.getServiceHealth.invalidate(),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("refreshError.title"),
|
|
||||||
message: t("refreshError.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [filter, setFilter] = useState<DockerReconciliationInboxFilter>("attention");
|
|
||||||
const [dismissedCandidateKeys, setDismissedCandidateKeys] = useLocalStorage<string[]>({
|
|
||||||
key: "homarr-docker-reconciliation-dismissed",
|
|
||||||
defaultValue: [],
|
|
||||||
});
|
|
||||||
const [serverOrigin, setServerOrigin] = useLocalStorage({
|
|
||||||
key: "homarr-docker-service-origin",
|
|
||||||
defaultValue: defaultServerOrigin,
|
|
||||||
});
|
|
||||||
const [urlMode, setUrlMode] = useLocalStorage<UrlTemplateMode>({
|
|
||||||
key: "homarr-docker-service-url-mode",
|
|
||||||
defaultValue: "hostPort",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (reconciliation.isError) {
|
|
||||||
return (
|
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("loadError.title")}>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm">{t("loadError.message")}</Text>
|
|
||||||
<Button variant="light" color="red" size="xs" w="fit-content" onClick={() => void reconciliation.refetch()}>
|
|
||||||
{tGlobal("common.action.tryAgain")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!reconciliation.data) return null;
|
|
||||||
|
|
||||||
const unavailableEndpoints = reconciliation.data.endpoints.filter(({ status }) => status === "unavailable");
|
|
||||||
const candidates = filterDockerReconciliationInbox(reconciliation.data.candidates, filter, dismissedCandidateKeys);
|
|
||||||
const attentionCount = reconciliation.data.candidates.filter(
|
|
||||||
({ candidateKey, state }) =>
|
|
||||||
["newRecognized", "newApp", "moved"].includes(state) && !dismissedCandidateKeys.includes(candidateKey),
|
|
||||||
).length;
|
|
||||||
const isRefreshing = refreshInventory.isPending || reconciliation.isFetching || health.isFetching;
|
|
||||||
const toggleLabel = isOpen ? tGlobal("common.action.hide") : t("action.review");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper withBorder p="sm">
|
|
||||||
<Stack gap={isOpen ? "sm" : 0}>
|
|
||||||
<Group justify="space-between" align="start">
|
|
||||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<ThemeIcon variant="light" size="lg" radius="md">
|
|
||||||
<IconBrandDocker size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Text fw={600}>{t("title")}</Text>
|
|
||||||
<Badge variant="light" color={attentionCount > 0 ? "blue" : "gray"} aria-live="polite">
|
|
||||||
{t("suggestions", { count: String(attentionCount) })}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="compact-sm"
|
|
||||||
rightSection={
|
|
||||||
<IconChevronDown
|
|
||||||
size={15}
|
|
||||||
style={{ transform: isOpen ? "rotate(180deg)" : undefined, transition: "transform 150ms ease" }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
aria-controls={panelId}
|
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
|
||||||
>
|
|
||||||
{toggleLabel}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Collapse id={panelId} expanded={isOpen}>
|
|
||||||
<Divider mb="sm" />
|
|
||||||
<Stack gap="sm">
|
|
||||||
<ServiceUrlTemplate
|
|
||||||
serverOrigin={serverOrigin}
|
|
||||||
onServerOriginChange={setServerOrigin}
|
|
||||||
mode={urlMode}
|
|
||||||
onModeChange={setUrlMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<SegmentedControl
|
|
||||||
size="xs"
|
|
||||||
value={filter}
|
|
||||||
onChange={(value) => setFilter(value as DockerReconciliationInboxFilter)}
|
|
||||||
data={[
|
|
||||||
{ value: "attention", label: t("filter.attention") },
|
|
||||||
{ value: "represented", label: t("filter.represented") },
|
|
||||||
{ value: "all", label: t("filter.all") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Group gap="xs">
|
|
||||||
{dismissedCandidateKeys.length > 0 && (
|
|
||||||
<Button variant="subtle" size="compact-xs" onClick={() => setDismissedCandidateKeys([])}>
|
|
||||||
{t("action.restoreDismissed", { count: String(dismissedCandidateKeys.length) })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="compact-xs"
|
|
||||||
loading={isRefreshing}
|
|
||||||
leftSection={<IconRefresh size={14} />}
|
|
||||||
onClick={() => refreshInventory.mutate()}
|
|
||||||
>
|
|
||||||
{tGlobal("docker.action.refresh.label")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{unavailableEndpoints.length > 0 && (
|
|
||||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
|
||||||
{t("endpointUnavailable", { names: unavailableEndpoints.map(({ name }) => name).join(", ") })}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{candidates.length === 0 ? (
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{reconciliation.data.candidates.length === 0 ? t("empty") : t("emptyFilter")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Accordion multiple variant="separated" radius="sm" chevron={null}>
|
|
||||||
{candidates.map((candidate) => (
|
|
||||||
<DockerReconciliationCandidate
|
|
||||||
key={candidate.candidateKey}
|
|
||||||
candidate={candidate}
|
|
||||||
health={health.data?.services.find(({ key }) => key === candidate.candidateKey)}
|
|
||||||
serverOrigin={serverOrigin}
|
|
||||||
urlMode={urlMode}
|
|
||||||
onDismiss={() =>
|
|
||||||
setDismissedCandidateKeys((current) =>
|
|
||||||
dismissDockerReconciliationCandidate(current, candidate.candidateKey),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Collapse>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DockerReconciliationCandidate = ({
|
|
||||||
candidate,
|
|
||||||
health,
|
|
||||||
serverOrigin,
|
|
||||||
urlMode,
|
|
||||||
onDismiss,
|
|
||||||
}: {
|
|
||||||
candidate: ReconciliationCandidate;
|
|
||||||
health: ServiceHealth | undefined;
|
|
||||||
serverOrigin: string;
|
|
||||||
urlMode: UrlTemplateMode;
|
|
||||||
onDismiss: () => void;
|
|
||||||
}) => {
|
|
||||||
const t = useScopedI18n("docker.reconciliation");
|
|
||||||
const tGlobal = useI18n();
|
|
||||||
const { openModal: openAppModal } = useModalAction(AddDockerAppToHomarr);
|
|
||||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
|
||||||
const templateUrl = getTemplateUrl(candidate, serverOrigin, urlMode);
|
|
||||||
const urlSuggestions = Array.from(
|
|
||||||
new Set([templateUrl, ...candidate.urlCandidates.map(({ url }) => url)].filter((url) => url.length > 0)),
|
|
||||||
);
|
|
||||||
const initialUrl = urlSuggestions[0] ?? "";
|
|
||||||
const [url, setUrl] = useState(initialUrl);
|
|
||||||
const isUrlEdited = useRef(false);
|
|
||||||
const target = getCandidateTarget(candidate);
|
|
||||||
const actionNeedsUrl = target.kind === "createApp" || target.kind === "setupIntegration";
|
|
||||||
const visibleHealthLayers =
|
|
||||||
health?.layers.filter(({ status }) => status !== "notApplicable" && status !== "notObserved") ?? [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUrlEdited.current) return;
|
|
||||||
setUrl(initialUrl);
|
|
||||||
}, [initialUrl]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion.Item value={candidate.candidateKey}>
|
|
||||||
<Group wrap="nowrap" gap={4} pr="sm">
|
|
||||||
<Accordion.Control
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
icon={
|
|
||||||
<Avatar src={candidate.container.iconUrl} radius="sm" size="sm">
|
|
||||||
{candidate.container.name.at(0)?.toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text fw={600} size="sm" truncate>
|
|
||||||
{candidate.container.name}
|
|
||||||
</Text>
|
|
||||||
<Badge color={stateColor(candidate.state)} variant="light" size="xs">
|
|
||||||
{t(`state.${candidate.state}`)}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{candidate.endpointName}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Accordion.Control>
|
|
||||||
<Group gap={4} wrap="nowrap">
|
|
||||||
{target.kind === "createApp" && (
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="compact-xs"
|
|
||||||
leftSection={<IconApps size={14} />}
|
|
||||||
onClick={() =>
|
|
||||||
openAppModal({
|
|
||||||
selectedContainers: [candidate.container],
|
|
||||||
initialUrls: [(normalizeServiceUrl(url) ?? url) || null],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{tGlobal("integration.field.createApp.label")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{target.kind === "setupIntegration" && (
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="compact-xs"
|
|
||||||
leftSection={<IconPlugConnected size={14} />}
|
|
||||||
onClick={() =>
|
|
||||||
openIntegrationModal({
|
|
||||||
initialKind: target.integrationKind,
|
|
||||||
initialName: candidate.container.name,
|
|
||||||
initialUrl: normalizeServiceUrl(url) ?? url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("action.setupIntegration")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{(target.kind === "reviewIntegration" || target.kind === "viewRepresentation") && (
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
href={target.href}
|
|
||||||
variant="light"
|
|
||||||
size="compact-xs"
|
|
||||||
leftSection={target.kind === "reviewIntegration" ? <IconSettings size={14} /> : <IconEye size={14} />}
|
|
||||||
>
|
|
||||||
{t(`action.${target.kind}`)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Tooltip label={t("action.dismiss")} events={{ hover: true, focus: true, touch: false }}>
|
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("action.dismiss")} onClick={onDismiss}>
|
|
||||||
<IconEyeOff size={15} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Accordion.Panel>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{candidate.representation.signals.ambiguous && (
|
|
||||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
|
||||||
{t("ambiguous")}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{visibleHealthLayers.length > 0 && (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="xs">
|
|
||||||
{visibleHealthLayers.map((layer) => (
|
|
||||||
<Group key={layer.layer} gap="xs" wrap="nowrap">
|
|
||||||
<HealthStatusIcon status={layer.status} />
|
|
||||||
<Text size="xs">
|
|
||||||
{t(`health.layer.${layer.layer}`)} ·{" "}
|
|
||||||
<Text component="span" c="dimmed" inherit>
|
|
||||||
{t(`health.status.${layer.status}`)}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{actionNeedsUrl && (
|
|
||||||
<Autocomplete
|
|
||||||
label={tGlobal("integration.field.url.label")}
|
|
||||||
value={url}
|
|
||||||
data={urlSuggestions}
|
|
||||||
placeholder={t("url.manual")}
|
|
||||||
onChange={(value) => {
|
|
||||||
isUrlEdited.current = true;
|
|
||||||
setUrl(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Accordion.Panel>
|
|
||||||
</Accordion.Item>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCandidateTarget = (candidate: ReconciliationCandidate) => {
|
|
||||||
if (candidate.representation.signals.ambiguous) {
|
|
||||||
if (candidate.nextAction === "reviewIntegration" && candidate.match) {
|
|
||||||
const params = new URLSearchParams({ search: getIntegrationName(candidate.match.kind) });
|
|
||||||
return { kind: "reviewIntegration" as const, href: `/manage/integrations?${params.toString()}` };
|
|
||||||
}
|
|
||||||
return { kind: "viewRepresentation" as const, href: "/manage/apps" };
|
|
||||||
}
|
|
||||||
if (candidate.state === "newRecognized" && candidate.match) {
|
|
||||||
return { kind: "setupIntegration" as const, integrationKind: candidate.match.kind };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate.state === "newApp") return { kind: "createApp" as const };
|
|
||||||
if (candidate.representation.integration) {
|
|
||||||
return {
|
|
||||||
kind: candidate.state === "moved" ? ("reviewIntegration" as const) : ("viewRepresentation" as const),
|
|
||||||
href: `/manage/integrations/edit/${candidate.representation.integration.id}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (candidate.representation.app) {
|
|
||||||
return { kind: "viewRepresentation" as const, href: `/manage/apps/edit/${candidate.representation.app.id}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { kind: "createApp" as const };
|
|
||||||
};
|
|
||||||
|
|
||||||
const stateColor = (state: ReconciliationCandidate["state"]) => {
|
|
||||||
if (state === "linked" || state === "represented") return "green";
|
|
||||||
if (state === "moved") return "yellow";
|
|
||||||
return "blue";
|
|
||||||
};
|
|
||||||
|
|
||||||
const HealthStatusIcon = ({ status }: { status: ServiceHealth["layers"][number]["status"] }) => {
|
|
||||||
let color = "gray";
|
|
||||||
let icon = <IconMinus size={12} />;
|
|
||||||
|
|
||||||
if (["available", "configured", "linked"].includes(status)) {
|
|
||||||
color = "green";
|
|
||||||
icon = <IconCheck size={12} />;
|
|
||||||
} else if (["missing", "changed", "unused"].includes(status)) {
|
|
||||||
color = "yellow";
|
|
||||||
icon = <IconAlertTriangle size={12} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeIcon color={color} variant="light" radius="xl" size="sm">
|
|
||||||
{icon}
|
|
||||||
</ThemeIcon>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { ActionIcon, Alert, Avatar, Badge, Box, Button, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
|
||||||
IconCategoryPlus,
|
IconCategoryPlus,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
@@ -20,10 +20,10 @@ import { MantineReactTable } from "mantine-react-table";
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { formatBytes, useTimeAgo } from "@homarr/common";
|
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||||
import type { ContainerState, DockerEndpointCapability } from "@homarr/docker";
|
import type { ContainerState } from "@homarr/docker";
|
||||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
|
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -32,41 +32,9 @@ import { OverflowBadge } from "@homarr/ui";
|
|||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||||
type DockerEndpoint = RouterOutputs["docker"]["getContainers"]["endpoints"][number];
|
|
||||||
|
|
||||||
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
|
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
||||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
|
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||||
|
|
||||||
const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id });
|
|
||||||
|
|
||||||
const showContainerActionResult = (
|
|
||||||
action: "start" | "stop" | "restart" | "remove",
|
|
||||||
containers: DockerContainer[],
|
|
||||||
results: RouterOutputs["docker"]["startAll"],
|
|
||||||
t: TranslationFunction,
|
|
||||||
) => {
|
|
||||||
const failures = results.filter((result) => !result.success);
|
|
||||||
if (failures.length === 0) {
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t(`docker.action.${action}.notification.success.title`),
|
|
||||||
message: t(`docker.action.${action}.notification.success.message`),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedNames = failures.map(
|
|
||||||
({ target }) =>
|
|
||||||
containers.find(({ endpointId, id }) => endpointId === target.endpointId && id === target.id)?.name ?? target.id,
|
|
||||||
);
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("docker.action.result.failure.title"),
|
|
||||||
message: t("docker.action.result.failure.message", {
|
|
||||||
failed: String(failures.length),
|
|
||||||
total: String(results.length),
|
|
||||||
names: failedNames.join(", "),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
||||||
{
|
{
|
||||||
@@ -189,39 +157,32 @@ interface DockerTableProps {
|
|||||||
export function DockerTable({ initialData }: DockerTableProps) {
|
export function DockerTable({ initialData }: DockerTableProps) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tDocker = useScopedI18n("docker");
|
const tDocker = useScopedI18n("docker");
|
||||||
const utils = clientApi.useUtils();
|
const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
|
||||||
initialData,
|
initialData,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
});
|
});
|
||||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
|
||||||
async onSuccess() {
|
|
||||||
await Promise.all([
|
|
||||||
utils.docker.getContainers.invalidate(),
|
|
||||||
utils.docker.reconcileServices.invalidate(),
|
|
||||||
utils.docker.getServiceHealth.invalidate(),
|
|
||||||
]);
|
|
||||||
showSuccessNotification({
|
|
||||||
title: tDocker("action.refresh.notification.success.title"),
|
|
||||||
message: tDocker("action.refresh.notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: tDocker("action.refresh.notification.error.title"),
|
|
||||||
message: tDocker("action.refresh.notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||||
|
const mutate = useCallback(() => {
|
||||||
|
void refetch().then((result) => {
|
||||||
|
if (result.isError) {
|
||||||
|
showErrorNotification({
|
||||||
|
title: tDocker("action.refresh.notification.error.title"),
|
||||||
|
message: tDocker("action.refresh.notification.error.message"),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: tDocker("action.refresh.notification.success.title"),
|
||||||
|
message: tDocker("action.refresh.notification.success.message"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [refetch, tDocker]);
|
||||||
|
|
||||||
const containers = data?.containers ?? [];
|
const containers = data?.containers ?? [];
|
||||||
const unavailableEndpoints = data?.endpoints.filter(({ status }) => status === "unavailable") ?? [];
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data: containers,
|
data: containers,
|
||||||
getRowId: (row) => row.resourceId,
|
state: { isLoading: isFetching },
|
||||||
state: { isLoading: isFetching || refreshInventory.isPending },
|
|
||||||
enableDensityToggle: false,
|
enableDensityToggle: false,
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
enableColumnFilters: false,
|
enableColumnFilters: false,
|
||||||
@@ -246,18 +207,13 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
|||||||
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
|
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
|
||||||
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
|
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
|
||||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
||||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => (
|
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
||||||
<ContainerRowMenu
|
|
||||||
container={row.original}
|
|
||||||
endpoint={data?.endpoints.find(({ id }) => id === row.original.endpointId)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
renderTopToolbarCustomActions: () => (
|
renderTopToolbarCustomActions: () => (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
rightSection={<IconRefresh size="1rem" />}
|
rightSection={<IconRefresh size="1rem" />}
|
||||||
onClick={() => refreshInventory.mutate()}
|
onClick={() => mutate()}
|
||||||
loading={isFetching || refreshInventory.isPending}
|
loading={isFetching}
|
||||||
>
|
>
|
||||||
{tDocker("action.refresh.label")}
|
{tDocker("action.refresh.label")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -273,7 +229,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
|||||||
totalCount: String(table.getRowCount()),
|
totalCount: String(table.getRowCount()),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<ContainerActionBar selectedContainers={dockerContainers} endpoints={data?.endpoints ?? []} />
|
<ContainerActionBar selectedContainers={dockerContainers} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -282,18 +238,10 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<>
|
||||||
{unavailableEndpoints.length > 0 && (
|
|
||||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
|
||||||
{tDocker("error.endpointUnavailable", {
|
|
||||||
count: String(unavailableEndpoints.length),
|
|
||||||
names: unavailableEndpoints.map(({ name }) => name).join(", "),
|
|
||||||
})}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
|
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
|
||||||
<MantineReactTable table={table} />
|
<MantineReactTable table={table} />
|
||||||
</Stack>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,21 +252,22 @@ const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon:
|
|||||||
{ action: "remove", icon: IconTrash, color: "red" },
|
{ action: "remove", icon: IconTrash, color: "red" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer; endpoint?: DockerEndpoint }) => {
|
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||||
const t = useScopedI18n("docker.action");
|
const t = useScopedI18n("docker.action");
|
||||||
const tAll = useI18n();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||||
const confirmRemoval = useDockerContainerRemovalConfirmation();
|
|
||||||
|
|
||||||
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
|
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
|
||||||
clientApi.docker[`${action}All`].useMutation({
|
clientApi.docker[`${action}All`].useMutation({
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await utils.docker.getContainers.invalidate();
|
await utils.docker.getContainers.invalidate();
|
||||||
},
|
},
|
||||||
onSuccess(results) {
|
onSuccess() {
|
||||||
showContainerActionResult(action, [container], results, tAll);
|
showSuccessNotification({
|
||||||
|
title: t(`${action}.notification.success.title`),
|
||||||
|
message: t(`${action}.notification.success.message`),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -345,7 +294,6 @@ const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer;
|
|||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconFileText size="1rem" />}
|
leftSection={<IconFileText size="1rem" />}
|
||||||
disabled={!endpointHasCapability(endpoint, "logs")}
|
|
||||||
onClick={() => router.push(createContainerLogsPath(container))}
|
onClick={() => router.push(createContainerLogsPath(container))}
|
||||||
>
|
>
|
||||||
{t("logs.label")}
|
{t("logs.label")}
|
||||||
@@ -356,12 +304,7 @@ const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer;
|
|||||||
key={action}
|
key={action}
|
||||||
leftSection={<Icon size="1rem" />}
|
leftSection={<Icon size="1rem" />}
|
||||||
color={color}
|
color={color}
|
||||||
disabled={!endpointHasCapability(endpoint, action === "remove" ? "remove" : "lifecycle")}
|
onClick={() => mutations[action].mutate({ ids: [container.id] })}
|
||||||
onClick={() => {
|
|
||||||
const runAction = () => mutations[action].mutate({ targets: [getContainerTarget(container)] });
|
|
||||||
if (action === "remove") confirmRemoval([container], runAction);
|
|
||||||
else runAction();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t(`${action}.label`)}
|
{t(`${action}.label`)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -381,24 +324,18 @@ const ContainerRowMenu = ({ container, endpoint }: { container: DockerContainer;
|
|||||||
|
|
||||||
interface ContainerActionBarProps {
|
interface ContainerActionBarProps {
|
||||||
selectedContainers: DockerContainer[];
|
selectedContainers: DockerContainer[];
|
||||||
endpoints: DockerEndpoint[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerActionBar = ({ selectedContainers, endpoints }: ContainerActionBarProps) => {
|
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||||
const t = useScopedI18n("docker.action");
|
const t = useScopedI18n("docker.action");
|
||||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||||
|
|
||||||
|
const selectedIds = selectedContainers.map((container) => container.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{containerActions.map(({ action, icon, color }) => (
|
{containerActions.map(({ action, icon, color }) => (
|
||||||
<ContainerActionBarButton
|
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
|
||||||
key={action}
|
|
||||||
icon={icon}
|
|
||||||
color={color}
|
|
||||||
action={action}
|
|
||||||
selectedContainers={selectedContainers}
|
|
||||||
endpoints={endpoints}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconCategoryPlus />}
|
leftSection={<IconCategoryPlus />}
|
||||||
@@ -416,22 +353,12 @@ interface ContainerActionBarButtonProps {
|
|||||||
icon: TablerIcon;
|
icon: TablerIcon;
|
||||||
color: MantineColor;
|
color: MantineColor;
|
||||||
action: "start" | "stop" | "restart" | "remove";
|
action: "start" | "stop" | "restart" | "remove";
|
||||||
selectedContainers: DockerContainer[];
|
selectedIds: string[];
|
||||||
endpoints: DockerEndpoint[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||||
const t = useScopedI18n("docker.action");
|
const t = useScopedI18n("docker.action");
|
||||||
const tAll = useI18n();
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const confirmRemoval = useDockerContainerRemovalConfirmation();
|
|
||||||
const requiredCapability = props.action === "remove" ? "remove" : "lifecycle";
|
|
||||||
const isAllowed = props.selectedContainers.every((container) =>
|
|
||||||
endpointHasCapability(
|
|
||||||
props.endpoints.find(({ id }) => id === container.endpointId),
|
|
||||||
requiredCapability,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation({
|
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation({
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
@@ -440,28 +367,23 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleClickAsync = async () => {
|
const handleClickAsync = async () => {
|
||||||
const runActionAsync = async () => {
|
await mutateAsync(
|
||||||
await mutateAsync(
|
{ ids: props.selectedIds },
|
||||||
{ targets: props.selectedContainers.map(getContainerTarget) },
|
{
|
||||||
{
|
onSuccess() {
|
||||||
onSuccess(results) {
|
showSuccessNotification({
|
||||||
showContainerActionResult(props.action, props.selectedContainers, results, tAll);
|
title: t(`${props.action}.notification.success.title`),
|
||||||
},
|
message: t(`${props.action}.notification.success.message`),
|
||||||
onError() {
|
});
|
||||||
showErrorNotification({
|
|
||||||
title: t(`${props.action}.notification.error.title`),
|
|
||||||
message: t(`${props.action}.notification.error.message`),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
onError() {
|
||||||
};
|
showErrorNotification({
|
||||||
|
title: t(`${props.action}.notification.error.title`),
|
||||||
if (props.action === "remove") {
|
message: t(`${props.action}.notification.error.message`),
|
||||||
confirmRemoval(props.selectedContainers, runActionAsync);
|
});
|
||||||
return;
|
},
|
||||||
}
|
},
|
||||||
await runActionAsync();
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -470,7 +392,6 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
|||||||
color={props.color}
|
color={props.color}
|
||||||
onClick={handleClickAsync}
|
onClick={handleClickAsync}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
disabled={!isAllowed}
|
|
||||||
variant="light"
|
variant="light"
|
||||||
>
|
>
|
||||||
{t(`${props.action}.label`)}
|
{t(`${props.action}.label`)}
|
||||||
@@ -478,9 +399,6 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpointHasCapability = (endpoint: DockerEndpoint | undefined, capability: DockerEndpointCapability) =>
|
|
||||||
endpoint && "capabilities" in endpoint ? endpoint.capabilities.includes(capability) : true;
|
|
||||||
|
|
||||||
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||||
const t = useScopedI18n("docker.field.state.option");
|
const t = useScopedI18n("docker.field.state.option");
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ClientSideDockerLogsTerminal } from "./client";
|
|||||||
|
|
||||||
interface DockerContainerLogsPageProps {
|
interface DockerContainerLogsPageProps {
|
||||||
params: Promise<{ containerId: string }>;
|
params: Promise<{ containerId: string }>;
|
||||||
searchParams: Promise<{ endpointId?: string; name?: string }>;
|
searchParams: Promise<{ name?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
|
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
|
||||||
@@ -23,12 +23,10 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { containerId } = await params;
|
const { containerId } = await params;
|
||||||
const { endpointId, name } = await searchParams;
|
const { name } = await searchParams;
|
||||||
const resolvedEndpointId = endpointId ?? (await getOnlyDockerEndpointIdAsync());
|
|
||||||
if (!resolvedEndpointId) notFound();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.docker.logs({ endpointId: resolvedEndpointId, id: containerId, tail: 1 });
|
await api.docker.logs({ id: containerId, tail: 1 });
|
||||||
} catch {
|
} catch {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -42,17 +40,8 @@ export default async function DockerContainerLogsPage({ params, searchParams }:
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||||
<ClientSideDockerLogsTerminal endpointId={resolvedEndpointId} containerId={containerId} />
|
<ClientSideDockerLogsTerminal containerId={containerId} />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOnlyDockerEndpointIdAsync = async () => {
|
|
||||||
try {
|
|
||||||
const endpoints = await api.docker.getEndpoints();
|
|
||||||
return endpoints.length === 1 ? endpoints[0]?.id : undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -11,11 +11,10 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import classes from "./terminal.module.css";
|
import classes from "./terminal.module.css";
|
||||||
|
|
||||||
interface DockerLogsTerminalProps {
|
interface DockerLogsTerminalProps {
|
||||||
endpointId: string;
|
|
||||||
containerId: string;
|
containerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DockerLogsTerminal = ({ endpointId, containerId }: DockerLogsTerminalProps) => {
|
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const terminalRef = useRef<Terminal | null>(null);
|
const terminalRef = useRef<Terminal | null>(null);
|
||||||
const pendingLogsRef = useRef<string[]>([]);
|
const pendingLogsRef = useRef<string[]>([]);
|
||||||
@@ -32,7 +31,7 @@ export const DockerLogsTerminal = ({ endpointId, containerId }: DockerLogsTermin
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
clientApi.docker.subscribeLogs.useSubscription(
|
clientApi.docker.subscribeLogs.useSubscription(
|
||||||
{ endpointId, id: containerId, tail: 200 },
|
{ id: containerId, tail: 200 },
|
||||||
{
|
{
|
||||||
onData: writeToTerminal,
|
onData: writeToTerminal,
|
||||||
onError(err) {
|
onError(err) {
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Stack, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { DockerReconciliation } from "./docker-reconciliation";
|
|
||||||
import { DockerTable } from "./docker-table";
|
import { DockerTable } from "./docker-table";
|
||||||
|
|
||||||
export default async function DockerPage() {
|
export default async function DockerPage() {
|
||||||
@@ -18,10 +15,7 @@ export default async function DockerPage() {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ containers, endpoints, timestamp }, requestHeaders] = await Promise.all([
|
const { containers, timestamp } = await api.docker.getContainers();
|
||||||
api.docker.getContainers(),
|
|
||||||
headers(),
|
|
||||||
]);
|
|
||||||
const tDocker = await getScopedI18n("docker");
|
const tDocker = await getScopedI18n("docker");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,8 +23,7 @@ export default async function DockerPage() {
|
|||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tDocker("title")}</Title>
|
<Title order={1}>{tDocker("title")}</Title>
|
||||||
<DockerReconciliation defaultServerOrigin={extractBaseUrlFromHeaders(requestHeaders)} />
|
<DockerTable initialData={{ containers, timestamp }} />
|
||||||
<DockerTable initialData={{ containers, endpoints, timestamp }} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,38 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Alert, SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
|
import { SimpleGrid, Skeleton, Stack, Title } from "@mantine/core";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import type { KubernetesLabelResourceType } from "@homarr/definitions";
|
import type { KubernetesLabelResourceType } from "@homarr/definitions";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import KubernetesErrorPage from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/error";
|
import KubernetesErrorPage from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/error";
|
||||||
import { HeaderCard } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card";
|
import { HeaderCard } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card";
|
||||||
import { ResourceGauge } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge";
|
import { ResourceGauge } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge";
|
||||||
import { ResourceTile } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile";
|
import { ResourceTile } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile";
|
||||||
import { useSelectedKubernetesContextId } from "../kubernetes-context-selector";
|
|
||||||
|
|
||||||
export function ClusterDashboard() {
|
export function ClusterDashboard() {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tCluster = useScopedI18n("kubernetes.cluster");
|
|
||||||
const contextId = useSelectedKubernetesContextId();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: clusterData,
|
data: clusterData,
|
||||||
isLoading: isClusterLoading,
|
isLoading: isClusterLoading,
|
||||||
isError: isClusterError,
|
isError: isClusterError,
|
||||||
} = clientApi.kubernetes.cluster.getCluster.useQuery({ contextId: contextId ?? "" }, { enabled: Boolean(contextId) });
|
} = clientApi.kubernetes.cluster.getCluster.useQuery();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: resourceCountsData,
|
data: resourceCountsData,
|
||||||
isLoading: isResourceCountsLoading,
|
isLoading: isResourceCountsLoading,
|
||||||
isError: isResourceCountsError,
|
isError: isResourceCountsError,
|
||||||
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery(
|
} = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery();
|
||||||
{ contextId: contextId ?? "" },
|
|
||||||
{ enabled: Boolean(contextId) },
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack bg="var(--mantine-color-body)">
|
<Stack bg="var(--mantine-color-body)">
|
||||||
@@ -53,12 +46,6 @@ export function ClusterDashboard() {
|
|||||||
|
|
||||||
<Title>{t("kubernetes.cluster.capacity.title")}</Title>
|
<Title>{t("kubernetes.cluster.capacity.title")}</Title>
|
||||||
|
|
||||||
{clusterData?.metricsAvailable === false && (
|
|
||||||
<Alert color="blue" variant="light" icon={<IconInfoCircle size={18} />}>
|
|
||||||
{tCluster("capacity.metricsUnavailable")}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
{isClusterError
|
{isClusterError
|
||||||
? Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />)
|
? Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />)
|
||||||
@@ -76,11 +63,9 @@ export function ClusterDashboard() {
|
|||||||
? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />)
|
? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />)
|
||||||
: isResourceCountsLoading
|
: isResourceCountsLoading
|
||||||
? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />)
|
? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />)
|
||||||
: contextId &&
|
: resourceCountsData?.map((clusterResourceCount) => (
|
||||||
resourceCountsData?.map((clusterResourceCount) => (
|
|
||||||
<ResourceTile
|
<ResourceTile
|
||||||
count={clusterResourceCount.count}
|
count={clusterResourceCount.count}
|
||||||
contextId={contextId}
|
|
||||||
label={clusterResourceCount.label as KubernetesLabelResourceType}
|
label={clusterResourceCount.label as KubernetesLabelResourceType}
|
||||||
key={clusterResourceCount.label}
|
key={clusterResourceCount.label}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,18 +11,13 @@ import classes from "./resource-tile.module.css";
|
|||||||
|
|
||||||
interface ResourceTileProps {
|
interface ResourceTileProps {
|
||||||
count: number;
|
count: number;
|
||||||
contextId: string;
|
|
||||||
label: KubernetesLabelResourceType;
|
label: KubernetesLabelResourceType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceTile(props: ResourceTileProps) {
|
export function ResourceTile(props: ResourceTileProps) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
|
||||||
component={Link}
|
|
||||||
href={`/manage/tools/kubernetes/${props.label}?context=${encodeURIComponent(props.contextId)}`}
|
|
||||||
className={classes.cardContainer}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
|||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface ConfigMapsTableComponentProps {
|
interface ConfigMapsTableComponentProps {
|
||||||
contextId: string;
|
|
||||||
initialConfigMaps: RouterOutputs["kubernetes"]["configMaps"]["getConfigMaps"];
|
initialConfigMaps: RouterOutputs["kubernetes"]["configMaps"]["getConfigMaps"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,19 +39,15 @@ const createColumns = (
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTableComponentProps) {
|
export function ConfigmapsTable(initialData: ConfigMapsTableComponentProps) {
|
||||||
const tConfigMaps = useScopedI18n("kubernetes.configmaps");
|
const tConfigMaps = useScopedI18n("kubernetes.configmaps");
|
||||||
|
|
||||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
|
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(undefined, {
|
||||||
{ contextId },
|
initialData: initialData.initialConfigMaps,
|
||||||
{
|
refetchOnMount: false,
|
||||||
initialData: initialConfigMaps,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: "always",
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: true,
|
});
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -8,24 +8,14 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { ConfigmapsTable } from "~/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table";
|
import { ConfigmapsTable } from "~/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
|
||||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
|
||||||
|
|
||||||
export default async function ConfigMapsPage({
|
export default async function ConfigMapsPage() {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<KubernetesContextSearchParams>;
|
|
||||||
}) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
const configMaps = await api.kubernetes.configMaps.getConfigMaps();
|
||||||
const configMaps =
|
|
||||||
context.status === "unavailable"
|
|
||||||
? []
|
|
||||||
: await api.kubernetes.configMaps.getConfigMaps({ contextId: context.contextId });
|
|
||||||
const tConfigMaps = await getScopedI18n("kubernetes.configmaps");
|
const tConfigMaps = await getScopedI18n("kubernetes.configmaps");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,7 +23,7 @@ export default async function ConfigMapsPage({
|
|||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tConfigMaps("label")}</Title>
|
<Title order={1}>{tConfigMaps("label")}</Title>
|
||||||
<ConfigmapsTable contextId={context.contextId} initialConfigMaps={configMaps} />
|
<ConfigmapsTable initialConfigMaps={configMaps} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface IngressesTableComponentProps {
|
interface IngressesTableComponentProps {
|
||||||
contextId: string;
|
|
||||||
initialIngresses: RouterOutputs["kubernetes"]["ingresses"]["getIngresses"];
|
initialIngresses: RouterOutputs["kubernetes"]["ingresses"]["getIngresses"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,19 +72,15 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.ingresses">): MR
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function IngressesTable({ contextId, initialIngresses }: IngressesTableComponentProps) {
|
export function IngressesTable(initialData: IngressesTableComponentProps) {
|
||||||
const tIngresses = useScopedI18n("kubernetes.ingresses");
|
const tIngresses = useScopedI18n("kubernetes.ingresses");
|
||||||
|
|
||||||
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
|
const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(undefined, {
|
||||||
{ contextId },
|
initialData: initialData.initialIngresses,
|
||||||
{
|
refetchOnMount: false,
|
||||||
initialData: initialIngresses,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: "always",
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: true,
|
});
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -110,9 +103,5 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
|
|||||||
columns: createColumns(tIngresses),
|
columns: createColumns(tIngresses),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <KubernetesErrorPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <MantineReactTable table={table} />;
|
return <MantineReactTable table={table} />;
|
||||||
}
|
}
|
||||||
@@ -8,31 +8,21 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { IngressesTable } from "~/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table";
|
import { IngressesTable } from "~/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
|
||||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
|
||||||
|
|
||||||
export default async function NamespacesPage({
|
export default async function NamespacesPage() {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<KubernetesContextSearchParams>;
|
|
||||||
}) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
const ingresses = await api.kubernetes.ingresses.getIngresses();
|
||||||
const ingresses =
|
|
||||||
context.status === "unavailable"
|
|
||||||
? []
|
|
||||||
: await api.kubernetes.ingresses.getIngresses({ contextId: context.contextId });
|
|
||||||
const tIngresses = await getScopedI18n("kubernetes.ingresses");
|
const tIngresses = await getScopedI18n("kubernetes.ingresses");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tIngresses("label")}</Title>
|
<Title order={1}>{tIngresses("label")}</Title>
|
||||||
<IngressesTable contextId={context.contextId} initialIngresses={ingresses} />
|
<IngressesTable initialIngresses={ingresses} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
"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";
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { Stack } from "@mantine/core";
|
|
||||||
|
|
||||||
import { KubernetesContextSelector } from "./kubernetes-context-selector";
|
|
||||||
|
|
||||||
export default function KubernetesLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<KubernetesContextSelector />
|
|
||||||
</Suspense>
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
|||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface NamespacesTableComponentProps {
|
interface NamespacesTableComponentProps {
|
||||||
contextId: string;
|
|
||||||
initialNamespaces: RouterOutputs["kubernetes"]["namespaces"]["getNamespaces"];
|
initialNamespaces: RouterOutputs["kubernetes"]["namespaces"]["getNamespaces"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,19 +56,15 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.namespaces">): M
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTableComponentProps) {
|
export function NamespacesTable(initialData: NamespacesTableComponentProps) {
|
||||||
const tNamespaces = useScopedI18n("kubernetes.namespaces");
|
const tNamespaces = useScopedI18n("kubernetes.namespaces");
|
||||||
|
|
||||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
|
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(undefined, {
|
||||||
{ contextId },
|
initialData: initialData.initialNamespaces,
|
||||||
{
|
refetchOnMount: false,
|
||||||
initialData: initialNamespaces,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: "always",
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: true,
|
});
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -8,31 +8,21 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table";
|
import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
|
||||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
|
||||||
|
|
||||||
export default async function NamespacesPage({
|
export default async function NamespacesPage() {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<KubernetesContextSearchParams>;
|
|
||||||
}) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
const namespaces = await api.kubernetes.namespaces.getNamespaces();
|
||||||
const namespaces =
|
|
||||||
context.status === "unavailable"
|
|
||||||
? []
|
|
||||||
: await api.kubernetes.namespaces.getNamespaces({ contextId: context.contextId });
|
|
||||||
const tNamespaces = await getScopedI18n("kubernetes.namespaces");
|
const tNamespaces = await getScopedI18n("kubernetes.namespaces");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tNamespaces("label")}</Title>
|
<Title order={1}>{tNamespaces("label")}</Title>
|
||||||
<NamespacesTable contextId={context.contextId} initialNamespaces={namespaces} />
|
<NamespacesTable initialNamespaces={namespaces} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,12 +15,9 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||||
|
|
||||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface NodesListComponentProps {
|
interface NodesListComponentProps {
|
||||||
contextId: string;
|
|
||||||
initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"];
|
initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +49,14 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
|
|||||||
accessorKey: "allocatableCpuPercentage",
|
accessorKey: "allocatableCpuPercentage",
|
||||||
header: t("field.cpu.label"),
|
header: t("field.cpu.label"),
|
||||||
Cell({ cell }) {
|
Cell({ cell }) {
|
||||||
return getRingProgress(cell.row.original.allocatableCpuPercentage, t("field.metricsUnavailable"));
|
return getRingProgress(cell.row.original.allocatableCpuPercentage);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "allocatableRamPercentage",
|
accessorKey: "allocatableRamPercentage",
|
||||||
header: t("field.memory.label"),
|
header: t("field.memory.label"),
|
||||||
Cell({ cell }) {
|
Cell({ cell }) {
|
||||||
return getRingProgress(cell.row.original.allocatableRamPercentage, t("field.metricsUnavailable"));
|
return getRingProgress(cell.row.original.allocatableRamPercentage);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -85,19 +82,15 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_Co
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NodesTable({ contextId, initialNodes }: NodesListComponentProps) {
|
export function NodesTable(initialData: NodesListComponentProps) {
|
||||||
const tNodes = useScopedI18n("kubernetes.nodes");
|
const tNodes = useScopedI18n("kubernetes.nodes");
|
||||||
|
|
||||||
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
|
const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(undefined, {
|
||||||
{ contextId },
|
initialData: initialData.initialNodes,
|
||||||
{
|
refetchOnMount: false,
|
||||||
initialData: initialNodes,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: "always",
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: true,
|
});
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -119,18 +112,10 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
|
|||||||
columns: createColumns(tNodes),
|
columns: createColumns(tNodes),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <KubernetesErrorPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <MantineReactTable table={table} />;
|
return <MantineReactTable table={table} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRingProgress(value: number | null, unavailableLabel: string) {
|
function getRingProgress(value: number) {
|
||||||
if (value === null) {
|
|
||||||
return <Text c="dimmed">{unavailableLabel}</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RingProgress
|
<RingProgress
|
||||||
size={70}
|
size={70}
|
||||||
|
|||||||
@@ -8,25 +8,21 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { NodesTable } from "~/app/[locale]/manage/tools/kubernetes/nodes/nodes-table";
|
import { NodesTable } from "~/app/[locale]/manage/tools/kubernetes/nodes/nodes-table";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
|
||||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
|
||||||
|
|
||||||
export default async function NodesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
export default async function NodesPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
const nodes = await api.kubernetes.nodes.getNodes();
|
||||||
const nodes =
|
|
||||||
context.status === "unavailable" ? [] : await api.kubernetes.nodes.getNodes({ contextId: context.contextId });
|
|
||||||
const tNodes = await getScopedI18n("kubernetes.nodes");
|
const tNodes = await getScopedI18n("kubernetes.nodes");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tNodes("label")}</Title>
|
<Title order={1}>{tNodes("label")}</Title>
|
||||||
<NodesTable contextId={context.contextId} initialNodes={nodes} />
|
<NodesTable initialNodes={nodes} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,25 +8,21 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table";
|
import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
|
||||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
|
||||||
|
|
||||||
export default async function PodsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
export default async function PodsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
const pods = await api.kubernetes.pods.getPods();
|
||||||
const pods =
|
|
||||||
context.status === "unavailable" ? [] : await api.kubernetes.pods.getPods({ contextId: context.contextId });
|
|
||||||
const tPods = await getScopedI18n("kubernetes.pods");
|
const tPods = await getScopedI18n("kubernetes.pods");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tPods("label")}</Title>
|
<Title order={1}>{tPods("label")}</Title>
|
||||||
<PodsTable contextId={context.contextId} initialPods={pods} />
|
<PodsTable initialPods={pods} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
|||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface PodsTableComponentProps {
|
interface PodsTableComponentProps {
|
||||||
contextId: string;
|
|
||||||
initialPods: RouterOutputs["kubernetes"]["pods"]["getPods"];
|
initialPods: RouterOutputs["kubernetes"]["pods"]["getPods"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,19 +47,15 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.pods">): MRT_Col
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
|
export function PodsTable(initialData: PodsTableComponentProps) {
|
||||||
const tPods = useScopedI18n("kubernetes.pods");
|
const tPods = useScopedI18n("kubernetes.pods");
|
||||||
|
|
||||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
|
const { data } = clientApi.kubernetes.pods.getPods.useQuery(undefined, {
|
||||||
{ contextId },
|
initialData: initialData.initialPods,
|
||||||
{
|
refetchOnMount: false,
|
||||||
initialData: initialPods,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: "always",
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: true,
|
});
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -8,25 +8,21 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table";
|
import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
|
||||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
|
||||||
|
|
||||||
export default async function SecretsPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
export default async function SecretsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
const secrets = await api.kubernetes.secrets.getSecrets();
|
||||||
const secrets =
|
|
||||||
context.status === "unavailable" ? [] : await api.kubernetes.secrets.getSecrets({ contextId: context.contextId });
|
|
||||||
const tSecrets = await getScopedI18n("kubernetes.secrets");
|
const tSecrets = await getScopedI18n("kubernetes.secrets");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tSecrets("label")}</Title>
|
<Title order={1}>{tSecrets("label")}</Title>
|
||||||
<SecretsTable contextId={context.contextId} initialSecrets={secrets} />
|
<SecretsTable initialSecrets={secrets} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
|||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface SecretsTableComponentProps {
|
interface SecretsTableComponentProps {
|
||||||
contextId: string;
|
|
||||||
initialSecrets: RouterOutputs["kubernetes"]["secrets"]["getSecrets"];
|
initialSecrets: RouterOutputs["kubernetes"]["secrets"]["getSecrets"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,19 +42,15 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.secrets">): MRT_
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponentProps) {
|
export function SecretsTable(initialData: SecretsTableComponentProps) {
|
||||||
const tSecrets = useScopedI18n("kubernetes.secrets");
|
const tSecrets = useScopedI18n("kubernetes.secrets");
|
||||||
|
|
||||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
|
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(undefined, {
|
||||||
{ contextId },
|
initialData: initialData.initialSecrets,
|
||||||
{
|
refetchOnMount: false,
|
||||||
initialData: initialSecrets,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: "always",
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: true,
|
});
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -8,25 +8,21 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table";
|
import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
|
||||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
|
||||||
|
|
||||||
export default async function ServicesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
export default async function ServicesPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
const services = await api.kubernetes.services.getServices();
|
||||||
const services =
|
|
||||||
context.status === "unavailable" ? [] : await api.kubernetes.services.getServices({ contextId: context.contextId });
|
|
||||||
const tServices = await getScopedI18n("kubernetes.services");
|
const tServices = await getScopedI18n("kubernetes.services");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tServices("label")}</Title>
|
<Title order={1}>{tServices("label")}</Title>
|
||||||
<ServicesTable contextId={context.contextId} initialServices={services} />
|
<ServicesTable initialServices={services} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
|||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface ServicesTableComponentProps {
|
interface ServicesTableComponentProps {
|
||||||
contextId: string;
|
|
||||||
initialServices: RouterOutputs["kubernetes"]["services"]["getServices"];
|
initialServices: RouterOutputs["kubernetes"]["services"]["getServices"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,19 +61,15 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.services">): MRT
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ServicesTable({ contextId, initialServices }: ServicesTableComponentProps) {
|
export function ServicesTable(initialData: ServicesTableComponentProps) {
|
||||||
const tServices = useScopedI18n("kubernetes.services");
|
const tServices = useScopedI18n("kubernetes.services");
|
||||||
|
|
||||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(
|
const { data } = clientApi.kubernetes.services.getServices.useQuery(undefined, {
|
||||||
{ contextId },
|
initialData: initialData.initialServices,
|
||||||
{
|
refetchOnMount: false,
|
||||||
initialData: initialServices,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: "always",
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: true,
|
});
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -8,25 +8,21 @@ import { getScopedI18n } from "@homarr/translation/server";
|
|||||||
|
|
||||||
import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table";
|
import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import type { KubernetesContextSearchParams } from "../kubernetes-context";
|
|
||||||
import { getSelectedKubernetesContextAsync } from "../kubernetes-context";
|
|
||||||
|
|
||||||
export default async function VolumesPage({ searchParams }: { searchParams: Promise<KubernetesContextSearchParams> }) {
|
export default async function VolumesPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getSelectedKubernetesContextAsync(searchParams);
|
const volumes = await api.kubernetes.volumes.getVolumes();
|
||||||
const volumes =
|
|
||||||
context.status === "unavailable" ? [] : await api.kubernetes.volumes.getVolumes({ contextId: context.contextId });
|
|
||||||
const tVolumes = await getScopedI18n("kubernetes.volumes");
|
const tVolumes = await getScopedI18n("kubernetes.volumes");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{tVolumes("label")}</Title>
|
<Title order={1}>{tVolumes("label")}</Title>
|
||||||
<VolumesTable contextId={context.contextId} initialVolumes={volumes} />
|
<VolumesTable initialVolumes={volumes} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
|||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface VolumesTableComponentProps {
|
interface VolumesTableComponentProps {
|
||||||
contextId: string;
|
|
||||||
initialVolumes: RouterOutputs["kubernetes"]["volumes"]["getVolumes"];
|
initialVolumes: RouterOutputs["kubernetes"]["volumes"]["getVolumes"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,19 +66,15 @@ const createColumns = (t: ScopedTranslationFunction<"kubernetes.volumes">): MRT_
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponentProps) {
|
export function VolumesTable(initialData: VolumesTableComponentProps) {
|
||||||
const tVolumes = useScopedI18n("kubernetes.volumes");
|
const tVolumes = useScopedI18n("kubernetes.volumes");
|
||||||
|
|
||||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
|
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(undefined, {
|
||||||
{ contextId },
|
initialData: initialData.initialVolumes,
|
||||||
{
|
refetchOnMount: false,
|
||||||
initialData: initialVolumes,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: "always",
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: true,
|
});
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -2,16 +2,37 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Transition,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconShieldExclamation } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { objectEntries } from "@homarr/common";
|
import { objectEntries, objectKeys } from "@homarr/common";
|
||||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
import type { GroupPermissionKey, PermissionMatrixCategory, PermissionMatrixState } from "@homarr/definitions";
|
||||||
import { groupPermissionKeys } from "@homarr/definitions";
|
import {
|
||||||
|
getPermissionsWithChildren,
|
||||||
|
groupPermissionKeys,
|
||||||
|
matrixStateToPermissions,
|
||||||
|
permissionMatrix,
|
||||||
|
permissionsToMatrixState,
|
||||||
|
} from "@homarr/definitions";
|
||||||
import { createFormContext } from "@homarr/form";
|
import { createFormContext } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
export type PermissionLabels = Partial<Record<GroupPermissionKey, string>>;
|
||||||
|
|
||||||
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
|
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
|
||||||
|
|
||||||
interface PermissionFormProps {
|
interface PermissionFormProps {
|
||||||
@@ -39,7 +60,7 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form onSubmit={(event) => event.preventDefault()}>
|
||||||
<FormProvider form={form}>{children}</FormProvider>
|
<FormProvider form={form}>{children}</FormProvider>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
@@ -47,15 +68,222 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
|
|||||||
|
|
||||||
type FormType = Record<GroupPermissionKey, boolean>;
|
type FormType = Record<GroupPermissionKey, boolean>;
|
||||||
|
|
||||||
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
|
type MatrixForm = ReturnType<typeof useFormContext>;
|
||||||
const form = useFormContext();
|
|
||||||
|
|
||||||
const props = form.getInputProps(name, {
|
const emptyMatrixState = (): PermissionMatrixState =>
|
||||||
withError: false,
|
objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||||
type: "checkbox",
|
acc[category] = { level: 0 };
|
||||||
|
return acc;
|
||||||
|
}, {} as PermissionMatrixState);
|
||||||
|
|
||||||
|
export const getMatrixLevel = (values: FormType, category: PermissionMatrixCategory): number => {
|
||||||
|
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
|
||||||
|
return permissionsToMatrixState(checkedKeys)[category].level;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyMatrixLevel = (form: MatrixForm, category: PermissionMatrixCategory, level: number) => {
|
||||||
|
const targetKeys = new Set(matrixStateToPermissions({ ...emptyMatrixState(), [category]: { level } }));
|
||||||
|
permissionMatrix[category].levels.forEach((key) => {
|
||||||
|
form.setFieldValue(key, targetKeys.has(key));
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return <Switch {...props} />;
|
const presets = {
|
||||||
|
viewer: { board: 1, app: 1, integration: 1, media: 1 },
|
||||||
|
editor: { board: 2, app: 2, integration: 2, "search-engine": 1, media: 1 },
|
||||||
|
admin: { admin: 1 },
|
||||||
|
} satisfies Record<string, Partial<Record<PermissionMatrixCategory, number>>>;
|
||||||
|
|
||||||
|
export type PermissionPreset = keyof typeof presets;
|
||||||
|
|
||||||
|
const presetToFormState = (preset: PermissionPreset): FormType => {
|
||||||
|
const levels: Partial<Record<PermissionMatrixCategory, number>> = presets[preset];
|
||||||
|
const state = objectKeys(permissionMatrix).reduce((acc, category) => {
|
||||||
|
acc[category] = { level: levels[category] ?? 0 };
|
||||||
|
return acc;
|
||||||
|
}, {} as PermissionMatrixState);
|
||||||
|
const enabledKeys = new Set(matrixStateToPermissions(state));
|
||||||
|
|
||||||
|
return groupPermissionKeys.reduce((acc, key) => {
|
||||||
|
acc[key] = enabledKeys.has(key);
|
||||||
|
return acc;
|
||||||
|
}, {} as FormType);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyPreset = (form: MatrixForm, preset: PermissionPreset) => {
|
||||||
|
form.setValues(presetToFormState(preset));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActivePreset = (values: FormType): PermissionPreset | null => {
|
||||||
|
return (
|
||||||
|
objectKeys(presets).find((preset) => {
|
||||||
|
const presetValues = presetToFormState(preset);
|
||||||
|
return groupPermissionKeys.every((key) => presetValues[key] === values[key]);
|
||||||
|
}) ?? null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MatrixImpliedHintProps {
|
||||||
|
category: PermissionMatrixCategory;
|
||||||
|
permissionLabels: PermissionLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MatrixImpliedHint = ({ category, permissionLabels }: MatrixImpliedHintProps) => {
|
||||||
|
const form = useFormContext();
|
||||||
|
const tPermissions = useScopedI18n("group.permission");
|
||||||
|
|
||||||
|
const level = getMatrixLevel(form.getValues(), category);
|
||||||
|
if (level === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedKeys: GroupPermissionKey[] = permissionMatrix[category].levels.slice(0, level);
|
||||||
|
const implied = getPermissionsWithChildren(checkedKeys).filter((key) => !checkedKeys.includes(key));
|
||||||
|
if (implied.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = implied.map((key) => permissionLabels[key] ?? key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{tPermissions("matrix.impliedHint", { permissions: labels.join(", ") })}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MatrixRowProps {
|
||||||
|
category: PermissionMatrixCategory;
|
||||||
|
permissionLabels: PermissionLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MatrixRow = ({ category, permissionLabels }: MatrixRowProps) => {
|
||||||
|
const form = useFormContext();
|
||||||
|
const tPermissions = useScopedI18n("group.permission");
|
||||||
|
|
||||||
|
const values = form.getValues();
|
||||||
|
const level = getMatrixLevel(values, category);
|
||||||
|
const isDanger = category === "admin";
|
||||||
|
const disabledByAdmin = !isDanger && getMatrixLevel(values, "admin") > 0;
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ value: "0", label: tPermissions("matrix.none") },
|
||||||
|
...permissionMatrix[category].levels.map((key, index) => ({
|
||||||
|
value: String(index + 1),
|
||||||
|
label: permissionLabels[key] ?? key,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap="sm" wrap="wrap" align="center">
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{isDanger && <IconShieldExclamation size={18} color="var(--mantine-color-red-8)" />}
|
||||||
|
<Text fw={500} c={isDanger ? "red.8" : undefined}>
|
||||||
|
{tPermissions(`${category}.title`)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
data={data}
|
||||||
|
value={String(level)}
|
||||||
|
disabled={disabledByAdmin}
|
||||||
|
onChange={(value) => applyMatrixLevel(form, category, Number(value))}
|
||||||
|
/>
|
||||||
|
{disabledByAdmin && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{tPermissions("matrix.grantedViaAdmin")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<MatrixImpliedHint category={category} permissionLabels={permissionLabels} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PresetButtons = () => {
|
||||||
|
const form = useFormContext();
|
||||||
|
const tPermissions = useScopedI18n("group.permission");
|
||||||
|
const activePreset = getActivePreset(form.getValues());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{tPermissions("matrix.preset.label")}
|
||||||
|
</Text>
|
||||||
|
{(["viewer", "editor", "admin"] as const).map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset}
|
||||||
|
size="xs"
|
||||||
|
variant={activePreset === preset ? "filled" : "default"}
|
||||||
|
onClick={() => applyPreset(form, preset)}
|
||||||
|
>
|
||||||
|
{tPermissions(`matrix.preset.${preset}`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{activePreset === null && <Badge variant="light">{tPermissions("matrix.preset.custom")}</Badge>}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EffectivePermissionPreviewProps {
|
||||||
|
permissionLabels: PermissionLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EffectivePermissionPreview = ({ permissionLabels }: EffectivePermissionPreviewProps) => {
|
||||||
|
const form = useFormContext();
|
||||||
|
const tPermissions = useScopedI18n("group.permission");
|
||||||
|
const [opened, { toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
|
const values = form.getValues();
|
||||||
|
const checkedKeys = groupPermissionKeys.filter((key) => values[key]);
|
||||||
|
const effective = getPermissionsWithChildren(checkedKeys);
|
||||||
|
|
||||||
|
const grouped = objectKeys(permissionMatrix)
|
||||||
|
.map((category) => ({
|
||||||
|
category,
|
||||||
|
keys: effective.filter((key) => key === category || key.startsWith(`${category}-`)),
|
||||||
|
}))
|
||||||
|
.filter(({ keys }) => keys.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<UnstyledButton onClick={toggle} w="100%" aria-expanded={opened}>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={500}>{tPermissions("matrix.effective.title")}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{tPermissions("matrix.effective.description")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Badge variant="light">{effective.length}</Badge>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
<Collapse expanded={opened}>
|
||||||
|
<Stack gap="xs" mt="sm">
|
||||||
|
{grouped.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{tPermissions("matrix.effective.empty")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
grouped.map(({ category, keys }) => (
|
||||||
|
<Group key={category} gap="xs" wrap="nowrap" align="flex-start">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{tPermissions(`${category}.title`)}
|
||||||
|
</Text>
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{keys.length}
|
||||||
|
</Badge>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{keys.map((key) => permissionLabels[key] ?? key).join(", ")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SaveAffixProps {
|
interface SaveAffixProps {
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import React from "react";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { objectKeys } from "@homarr/common";
|
import { objectKeys } from "@homarr/common";
|
||||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
import { groupPermissions } from "@homarr/definitions";
|
import { groupPermissions, permissionMatrix } from "@homarr/definitions";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
|
import type { PermissionLabels } from "./_group-permission-form";
|
||||||
|
import {
|
||||||
|
EffectivePermissionPreview,
|
||||||
|
MatrixRow,
|
||||||
|
PermissionForm,
|
||||||
|
PresetButtons,
|
||||||
|
SaveAffix,
|
||||||
|
} from "./_group-permission-form";
|
||||||
|
|
||||||
interface GroupPermissionsPageProps {
|
interface GroupPermissionsPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -26,27 +32,25 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const group = await api.group.getById({ id: params.id });
|
const group = await api.group.getById({ id: params.id });
|
||||||
const tPermissions = await getScopedI18n("group.permission");
|
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
|
const permissionLabels = await buildPermissionLabels();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title>{t("management.page.group.setting.permissions.title")}</Title>
|
<Title>{t("management.page.group.setting.permissions.title")}</Title>
|
||||||
|
|
||||||
<PermissionForm initialPermissions={group.permissions}>
|
<PermissionForm initialPermissions={group.permissions}>
|
||||||
<Stack pos="relative">
|
<Stack pos="relative">
|
||||||
{objectKeys(groupPermissions).map((group) => {
|
<PresetButtons />
|
||||||
const isDanger = group === "admin";
|
|
||||||
|
|
||||||
return (
|
<Stack gap="md">
|
||||||
<Stack key={group} gap="sm">
|
{objectKeys(permissionMatrix).map((category) => (
|
||||||
<Title order={2} c={isDanger ? "red.8" : undefined}>
|
<MatrixRow key={category} category={category} permissionLabels={permissionLabels} />
|
||||||
{tPermissions(`${group}.title`)}
|
))}
|
||||||
</Title>
|
</Stack>
|
||||||
<PermissionCard isDanger={isDanger} group={group} />
|
|
||||||
</Stack>
|
<EffectivePermissionPreview permissionLabels={permissionLabels} />
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<SaveAffix groupId={group.id} />
|
<SaveAffix groupId={group.id} />
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -55,66 +59,19 @@ export default async function GroupPermissionsPage(props: GroupPermissionsPagePr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PermissionCardProps {
|
const buildPermissionLabels = async (): Promise<PermissionLabels> => {
|
||||||
group: keyof typeof groupPermissions;
|
const permissionLabels: PermissionLabels = {};
|
||||||
isDanger: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
|
for (const category of objectKeys(groupPermissions)) {
|
||||||
const t = await getScopedI18n(`group.permission.${group}.item`);
|
const tItem = await getScopedI18n(`group.permission.${category}.item`);
|
||||||
const item = groupPermissions[group];
|
const item = groupPermissions[category];
|
||||||
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]);
|
const suffixes = typeof item !== "boolean" ? item : (["admin"] as const);
|
||||||
|
|
||||||
return (
|
suffixes.forEach((suffix) => {
|
||||||
<Card
|
const key = (typeof item !== "boolean" ? `${category}-${suffix}` : "admin") as GroupPermissionKey;
|
||||||
p="md"
|
permissionLabels[key] = tItem(`${suffix}.label`);
|
||||||
style={{
|
});
|
||||||
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{permissions.map((permission, index) => (
|
|
||||||
<React.Fragment key={permission}>
|
|
||||||
<PermissionRow
|
|
||||||
name={createGroupPermissionKey(group, permission)}
|
|
||||||
label={t(`${permission}.label`)}
|
|
||||||
description={t(`${permission}.description`)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{index < permissions.length - 1 && (
|
|
||||||
<CardSection>
|
|
||||||
<Divider />
|
|
||||||
</CardSection>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createGroupPermissionKey = (group: keyof typeof groupPermissions, permission: string): GroupPermissionKey => {
|
|
||||||
if (typeof groupPermissions[group] === "boolean") {
|
|
||||||
return group as GroupPermissionKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${group}-${permission}` as GroupPermissionKey;
|
return permissionLabels;
|
||||||
};
|
|
||||||
|
|
||||||
interface PermissionRowProps {
|
|
||||||
name: GroupPermissionKey;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text fw={500}>{label}</Text>
|
|
||||||
<Text c="gray.5">{description}</Text>
|
|
||||||
</Stack>
|
|
||||||
<PermissionSwitch name={name} />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,46 @@
|
|||||||
import { permanentRedirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { Alert, Button, Stack } from "@mantine/core";
|
||||||
|
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
/** The Workshop now lives as a tab of the Custom Widgets page. */
|
import { auth } from "@homarr/auth/next";
|
||||||
export default function WorkshopPage(): never {
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
permanentRedirect("/manage/custom-widgets/workshop");
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import path from "path";
|
|||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import BetterSqlite3 from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
|
||||||
@@ -18,9 +18,6 @@ import { env } from "@homarr/common/env";
|
|||||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
import { db } from "@homarr/db";
|
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";
|
import { findMigrationsFolder } from "../shared";
|
||||||
|
|
||||||
@@ -123,7 +120,7 @@ const assertArchiveSizeLimits = (zip: AdmZip) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const reEncryptSecrets = (tempDb: InstanceType<typeof BetterSqlite3>, importedKeyHex: string) => {
|
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
||||||
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
||||||
if (importedKeyHex === currentKeyHex) return;
|
if (importedKeyHex === currentKeyHex) return;
|
||||||
|
|
||||||
@@ -175,7 +172,7 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof BetterSqlite3>, importedKe
|
|||||||
transaction();
|
transaction();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getHomeBoardName = (tempDb: InstanceType<typeof BetterSqlite3>): string | null => {
|
const getHomeBoardName = (tempDb: InstanceType<typeof Database>): string | null => {
|
||||||
const row = tempDb
|
const row = tempDb
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT "name" FROM (
|
`SELECT "name" FROM (
|
||||||
@@ -237,7 +234,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
restoreInProgress = true;
|
restoreInProgress = true;
|
||||||
let tempDirectory: string | null = null;
|
let tempDirectory: string | null = null;
|
||||||
let tempDb: InstanceType<typeof BetterSqlite3> | null = null;
|
let tempDb: InstanceType<typeof Database> | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let formData: FormData;
|
let formData: FormData;
|
||||||
@@ -288,10 +285,9 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
fs.writeFileSync(tempPath, dbEntry.getData());
|
fs.writeFileSync(tempPath, dbEntry.getData());
|
||||||
|
|
||||||
tempDb = new BetterSqlite3(tempPath);
|
tempDb = new Database(tempPath);
|
||||||
const drizzleDb = drizzle(tempDb, { casing: DB_CASING, schema });
|
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
|
||||||
migrate(drizzleDb, { migrationsFolder });
|
migrate(drizzleDb, { migrationsFolder });
|
||||||
await applyCustomMigrationsAsync(drizzleDb as unknown as Database);
|
|
||||||
|
|
||||||
const metadataEntry = zip.getEntry("metadata.json");
|
const metadataEntry = zip.getEntry("metadata.json");
|
||||||
if (!metadataEntry) {
|
if (!metadataEntry) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export const findMigrationsFolder = (cwd = process.cwd()) => {
|
export const findMigrationsFolder = () => {
|
||||||
|
const cwd = process.cwd();
|
||||||
const migrationsFolderCandidates = [
|
const migrationsFolderCandidates = [
|
||||||
path.join(cwd, "db/migrations/sqlite"),
|
path.join(cwd, "db/migrations/sqlite"),
|
||||||
path.join(cwd, "packages/db/migrations/sqlite"),
|
path.join(cwd, "packages/db/migrations/sqlite"),
|
||||||
path.resolve(cwd, "../../packages/db/migrations/sqlite"),
|
path.resolve(cwd, "../../db/migrations/sqlite"),
|
||||||
"/app/db/migrations/sqlite",
|
"/app/db/migrations/sqlite",
|
||||||
];
|
];
|
||||||
return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
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\`).
|
**"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 \`targets\` containing the endpointId and id returned for each container.
|
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
|
||||||
|
|
||||||
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
|
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
boardName: "Home",
|
boardName: "Home",
|
||||||
kind: "audioStats",
|
kind: "audioStats",
|
||||||
summary: "Show audio statistics",
|
summary: "Show audio statistics",
|
||||||
integrationIds: ["navidrome-private", "navidrome-1", "audiobookshelf-1", "plex-1"],
|
integrationIds: ["navidrome-1", "audiobookshelf-1", "plex-1"],
|
||||||
},
|
},
|
||||||
settings,
|
settings,
|
||||||
[
|
[
|
||||||
@@ -55,28 +55,28 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
name: "Navidrome",
|
name: "Navidrome",
|
||||||
kind: "navidrome",
|
kind: "navidrome",
|
||||||
url: "https://music.example.com",
|
url: "https://music.example.com",
|
||||||
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
|
permissions: { hasUseAccess: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "audiobookshelf-1",
|
id: "audiobookshelf-1",
|
||||||
name: "Audiobookshelf",
|
name: "Audiobookshelf",
|
||||||
kind: "audiobookshelf",
|
kind: "audiobookshelf",
|
||||||
url: "https://books.example.com",
|
url: "https://books.example.com",
|
||||||
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
|
permissions: { hasUseAccess: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "plex-1",
|
id: "plex-1",
|
||||||
name: "Plex",
|
name: "Plex",
|
||||||
kind: "plex",
|
kind: "plex",
|
||||||
url: "https://plex.example.com",
|
url: "https://plex.example.com",
|
||||||
permissions: { hasUseAccess: true, hasInteractAccess: true, hasFullAccess: true },
|
permissions: { hasUseAccess: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "navidrome-private",
|
id: "navidrome-private",
|
||||||
name: "Private Navidrome",
|
name: "Private Navidrome",
|
||||||
kind: "navidrome",
|
kind: "navidrome",
|
||||||
url: "https://private.example.com",
|
url: "https://private.example.com",
|
||||||
permissions: { hasUseAccess: false, hasInteractAccess: false, hasFullAccess: false },
|
permissions: { hasUseAccess: false },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@ma
|
|||||||
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
@@ -21,10 +20,9 @@ import { useAssistantAutomaticAction } from "./assistant-auto-approval";
|
|||||||
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
||||||
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
|
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
|
||||||
import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts";
|
import type { ConfigureWidgetArgs, ConfigureWidgetResult } from "./assistant-tool-contracts";
|
||||||
import { IntegrationSelectModal } from "../integration/integration-select-modal";
|
|
||||||
|
|
||||||
interface AssistantIntegration extends IntegrationSelectOption {
|
interface AssistantIntegration extends IntegrationSelectOption {
|
||||||
permissions: NonNullable<IntegrationSelectOption["permissions"]>;
|
permissions: { hasUseAccess: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAssistantWidgetConfiguration = (
|
export const getAssistantWidgetConfiguration = (
|
||||||
@@ -68,7 +66,6 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
||||||
const t = useScopedI18n("common.assistant.configureWidget");
|
const t = useScopedI18n("common.assistant.configureWidget");
|
||||||
const fullT = useI18n();
|
const fullT = useI18n();
|
||||||
const { data: session } = useSession();
|
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||||
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
||||||
@@ -83,11 +80,10 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||||
const missingRequiredIntegration =
|
const missingRequiredIntegration =
|
||||||
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
||||||
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
const { openModal } = useModalAction(WidgetEditModal);
|
||||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
|
||||||
const autoConfirming = useAssistantAutomaticAction({
|
const autoConfirming = useAssistantAutomaticAction({
|
||||||
toolCallId,
|
toolCallId,
|
||||||
ready: result === undefined && configuration !== null && !missingRequiredIntegration,
|
ready: result === undefined && configuration !== null,
|
||||||
completed: result !== undefined,
|
completed: result !== undefined,
|
||||||
confirm: () => {
|
confirm: () => {
|
||||||
if (!args || !configuration) return;
|
if (!args || !configuration) return;
|
||||||
@@ -158,26 +154,6 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
|
|
||||||
if (!configuration) return null;
|
if (!configuration) return null;
|
||||||
|
|
||||||
const openConfigurationModal = (
|
|
||||||
value = configuration.value,
|
|
||||||
integrationData: IntegrationSelectOption[] = configuration.integrationData,
|
|
||||||
) =>
|
|
||||||
openWidgetModal(
|
|
||||||
{
|
|
||||||
kind: args.kind,
|
|
||||||
definition: widgetImports[args.kind].definition,
|
|
||||||
value,
|
|
||||||
integrationData,
|
|
||||||
integrationSupport: configuration.integrationSupport,
|
|
||||||
settings,
|
|
||||||
onSuccessfulEdit: ({ options, integrationIds }) =>
|
|
||||||
addResult({ boardId: args.boardId, kind: args.kind, options, integrationIds }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${args.kind}.name`)}`,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (autoConfirming) {
|
if (autoConfirming) {
|
||||||
return (
|
return (
|
||||||
<Box className={classes.appTool}>
|
<Box className={classes.appTool}>
|
||||||
@@ -192,32 +168,9 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="sm">{t("noIntegrationDescription")}</Text>
|
<Text size="sm">{t("noIntegrationDescription")}</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{session?.user.permissions.includes("integration-create") ? (
|
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
|
||||||
<Button
|
{t("manageIntegrations")}
|
||||||
size="compact-sm"
|
</Button>
|
||||||
variant="light"
|
|
||||||
onClick={() =>
|
|
||||||
openIntegrationModal({
|
|
||||||
allowedKinds:
|
|
||||||
definition && "supportedIntegrations" in definition
|
|
||||||
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
|
||||||
: [],
|
|
||||||
onSuccess: (created) => {
|
|
||||||
if (!created) return;
|
|
||||||
openConfigurationModal({ ...configuration.value, integrationIds: [created.integration.id] }, [
|
|
||||||
created.integration,
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("connectIntegration")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button component="a" href="/manage/integrations" size="compact-sm" variant="light">
|
|
||||||
{t("manageIntegrations")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -252,7 +205,28 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
<Button mt="md" fullWidth leftSection={<IconSettings size={17} />} onClick={() => openConfigurationModal()}>
|
<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`)}`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
{t("review")}
|
{t("review")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { waitForServerReadinessAsync } from "./database-restore-flow";
|
import { getStepAfterRestorePreview, waitForServerReadinessAsync } from "./database-restore-flow";
|
||||||
|
|
||||||
|
it("requires confirmation after every restore preview", () => {
|
||||||
|
expect(getStepAfterRestorePreview()).toBe("confirm");
|
||||||
|
});
|
||||||
|
|
||||||
describe("waitForServerReadinessAsync", () => {
|
describe("waitForServerReadinessAsync", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const RESTART_REQUEST_TIMEOUT_MS = 5_000;
|
|||||||
|
|
||||||
export type ServerReadinessResult = "ready" | "timedOut" | "aborted";
|
export type ServerReadinessResult = "ready" | "timedOut" | "aborted";
|
||||||
|
|
||||||
|
export const getStepAfterRestorePreview = (): RestoreStep => "confirm";
|
||||||
|
|
||||||
interface WaitForServerReadinessOptions {
|
interface WaitForServerReadinessOptions {
|
||||||
restartAfterMs: number;
|
restartAfterMs: number;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
@@ -254,14 +256,14 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
|||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group gap="sm" wrap="nowrap" miw={0}>
|
<Group gap="sm" wrap="nowrap" miw={0}>
|
||||||
<IconFileZip size={20} />
|
<IconFileZip size={20} />
|
||||||
<Group>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500} truncate>
|
<Text size="sm" fw={500} truncate>
|
||||||
{file?.name}
|
{file?.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}
|
{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Button variant="subtle" size="xs" onClick={handleClear} style={{ flexShrink: 0 }}>
|
<Button variant="subtle" size="xs" onClick={handleClear} style={{ flexShrink: 0 }}>
|
||||||
{t("changeFile")}
|
{t("changeFile")}
|
||||||
@@ -289,11 +291,8 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
|||||||
<>
|
<>
|
||||||
<BackupPreviewPanel analysis={analysis} />
|
<BackupPreviewPanel analysis={analysis} />
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button
|
<Button rightSection={<IconArrowRight size={16} />} onClick={() => setStep(getStepAfterRestorePreview())}>
|
||||||
rightSection={<IconArrowRight size={16} />}
|
{t("continueToRestore")}
|
||||||
onClick={() => (variant === "standalone" ? void handleConfirm() : setStep("confirm"))}
|
|
||||||
>
|
|
||||||
{variant === "standalone" ? t("confirm.submit") : t("continueToRestore")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
.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));
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
declare const classes: {
|
|
||||||
readonly panel: string;
|
|
||||||
readonly root: string;
|
|
||||||
readonly step: string;
|
|
||||||
};
|
|
||||||
export default classes;
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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,9 +4,8 @@ import type { Modify } from "@homarr/common/types";
|
|||||||
import { getRootSectionLane } from "@homarr/definitions";
|
import { getRootSectionLane } from "@homarr/definitions";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
|
||||||
|
|
||||||
import type { Board, ContainerSection, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||||
import { getFirstEmptyPosition } from "./empty-position";
|
import { getFirstEmptyPosition } from "./empty-position";
|
||||||
import { getSectionElements } from "./section-elements";
|
import { getSectionElements } from "./section-elements";
|
||||||
@@ -16,43 +15,33 @@ export interface CreateItemInput {
|
|||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
integrationIds?: string[];
|
integrationIds?: string[];
|
||||||
targetSectionId?: string;
|
|
||||||
advancedOptions?: BoardItemAdvancedOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createItemCallback =
|
export const createItemCallback =
|
||||||
({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) =>
|
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||||
(previous: Board): Board => {
|
(previous: Board): Board => {
|
||||||
const firstCanvasSection = previous.sections
|
const firstSection = previous.sections
|
||||||
.filter(
|
.filter(
|
||||||
(section): section is EmptySection =>
|
(section): section is EmptySection =>
|
||||||
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||||
)
|
)
|
||||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||||
.at(0);
|
.at(0);
|
||||||
const requestedSection = targetSectionId
|
|
||||||
? previous.sections.find(
|
|
||||||
(section): section is EmptySection | ContainerSection =>
|
|
||||||
section.id === targetSectionId && (section.kind === "empty" || section.kind === "container"),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
let targetSection = requestedSection ?? firstCanvasSection;
|
|
||||||
|
|
||||||
if (!targetSection) return previous;
|
if (!firstSection) return previous;
|
||||||
|
|
||||||
let itemLayouts = createItemLayouts(previous, targetSection, kind);
|
const itemLayouts = createItemLayouts(previous, firstSection, kind);
|
||||||
if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) {
|
|
||||||
targetSection = firstCanvasSection;
|
|
||||||
itemLayouts = createItemLayouts(previous, targetSection, kind);
|
|
||||||
}
|
|
||||||
if (itemLayouts.length === 0) return previous;
|
|
||||||
const widget = {
|
const widget = {
|
||||||
id: id ?? createId(),
|
id: id ?? createId(),
|
||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
layouts: itemLayouts,
|
layouts: itemLayouts,
|
||||||
integrationIds,
|
integrationIds,
|
||||||
advancedOptions: advancedOptions ?? { title: null, customCssClasses: [], borderColor: "" },
|
advancedOptions: {
|
||||||
|
title: null,
|
||||||
|
customCssClasses: [],
|
||||||
|
borderColor: "",
|
||||||
|
},
|
||||||
} satisfies Modify<
|
} satisfies Modify<
|
||||||
Item,
|
Item,
|
||||||
{
|
{
|
||||||
@@ -70,25 +59,14 @@ const defaultItemSize = { width: 1, height: 1 };
|
|||||||
|
|
||||||
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
|
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
|
||||||
|
|
||||||
const createItemLayouts = (
|
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => {
|
||||||
board: Board,
|
|
||||||
currentSection: EmptySection | ContainerSection,
|
|
||||||
kind: WidgetKind,
|
|
||||||
): ItemLayout[] => {
|
|
||||||
const layouts = getBoardLayouts(board);
|
const layouts = getBoardLayouts(board);
|
||||||
const itemSize = getWidgetItemSize(kind);
|
const itemSize = getWidgetItemSize(kind);
|
||||||
|
|
||||||
return layouts.flatMap((layoutId) => {
|
return layouts.map((layoutId) => {
|
||||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||||
const containerLayout =
|
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
|
||||||
currentSection.kind === "container"
|
|
||||||
? currentSection.layouts.find((layout) => layout.layoutId === layoutId)
|
|
||||||
: undefined;
|
|
||||||
const lane = currentSection.kind === "empty" ? getRootSectionLane(currentSection.xOffset) : "main";
|
|
||||||
if (currentSection.kind === "container" && !containerLayout) return [];
|
|
||||||
const columnCount = containerLayout?.width ?? (boardLayout ? getBoardLaneColumnCount(boardLayout, lane) : 0);
|
|
||||||
if (columnCount === 0) return [];
|
|
||||||
const size = {
|
const size = {
|
||||||
width: Math.min(itemSize.width, columnCount),
|
width: Math.min(itemSize.width, columnCount),
|
||||||
height: itemSize.height,
|
height: itemSize.height,
|
||||||
@@ -102,14 +80,12 @@ const createItemLayouts = (
|
|||||||
throw new Error("Your board is full");
|
throw new Error("Your board is full");
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return {
|
||||||
{
|
width: size.width,
|
||||||
width: size.width,
|
height: size.height,
|
||||||
height: size.height,
|
...emptyPosition,
|
||||||
...emptyPosition,
|
sectionId: currentSection.id,
|
||||||
sectionId: currentSection.id,
|
layoutId,
|
||||||
layoutId,
|
};
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||