merge: sync dev changes
This commit is contained in:
commit
5a21df86e2
233 files changed
+13722
-3815
No files matched your search
+2
-1
@@ -17,7 +17,8 @@ coverage
|
||||
dev
|
||||
.build
|
||||
e2e
|
||||
apps/docs
|
||||
apps/docs/*
|
||||
!apps/docs/package.json
|
||||
apps/nextjs/.next
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
- 1.72.0
|
||||
- 1.71.0
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: "[Dependency Updates] Auto Approve"
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-automatic-prs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175486441: homarr-releases[bot]
|
||||
# Extracted from https://api.github.com/users/<username>
|
||||
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to approve pull request
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
- name: Approve automatic PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
||||
@@ -0,0 +1,43 @@
|
||||
name: "[Bots] Reconcile Auto-merge"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/30 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to enable auto-merge
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Re-enable auto-merge for prepared bot PRs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175441: homarr-releases[bot]
|
||||
gh pr list --repo "${{ github.repository }}" --base dev --state open --limit 100 \
|
||||
--json number,author,reviewDecision,mergeStateStatus,autoMergeRequest \
|
||||
--jq '.[] | select(.author.login as $login | ["app/homarr-renovate", "app/homarr-crowdin", "app/homarr-update-contributors", "app/homarr-releases"] | index($login)) | select(.reviewDecision == "APPROVED") | select(.mergeStateStatus == "CLEAN") | select(.autoMergeRequest == null) | .number' \
|
||||
| while read -r pr; do
|
||||
echo "Re-enabling auto-merge on PR #$pr"
|
||||
gh pr merge "$pr" --auto --squash
|
||||
done
|
||||
@@ -0,0 +1,47 @@
|
||||
name: "[Bots] Approve and Auto-merge"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-and-automerge:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
# 158783068: homarr-renovate[bot]
|
||||
# 190541745: homarr-crowdin[bot]
|
||||
# 210161987: homarr-update-contributors[bot]
|
||||
# 175441: homarr-releases[bot]
|
||||
# Extracted from https://api.github.com/users/<username>
|
||||
if: |
|
||||
(github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175441) &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.base.ref == 'dev'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||
permission-pull-requests: write # required to approve a pull request and enable auto-merge
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: sudo apt-get install -y gh
|
||||
|
||||
- name: Approve and enable auto-merge
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: |
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
if [ "$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" != "APPROVED" ]; then
|
||||
gh pr review "$PR_NUMBER" --approve --body "Automatically approved by Homarr bot policy"
|
||||
fi
|
||||
if [ "$(gh pr view "$PR_NUMBER" --json autoMergeRequest --jq '.autoMergeRequest != null')" != "true" ]; then
|
||||
gh pr merge "$PR_NUMBER" --auto --squash
|
||||
fi
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Lint, typecheck, and build affected workspaces
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=100%
|
||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2
|
||||
|
||||
- name: Validate workspace configuration
|
||||
run: pnpm lint:ws
|
||||
|
||||
@@ -78,3 +78,4 @@ skills-lock.json
|
||||
apps/docs/.docusaurus
|
||||
apps/docs/build
|
||||
.pnpm-store
|
||||
benchmark-results
|
||||
@@ -1,3 +1,5 @@
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||
|
||||
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
|
||||
|
||||
+19
-8
@@ -3,12 +3,18 @@
|
||||
FROM node:24.18.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
ARG TARGETPLATFORM
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat curl bash && apk update
|
||||
# Native dependencies normally use prebuilds, but the build must remain
|
||||
# reproducible when a prebuild download times out and compilation is required.
|
||||
RUN apk add --no-cache libc6-compat curl bash python3 make g++
|
||||
|
||||
RUN corepack enable pnpm
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY patches ./patches
|
||||
COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./
|
||||
# @homarr/definitions generates documentation types during install.
|
||||
COPY --parents ./packages/definitions/src ./
|
||||
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
|
||||
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
||||
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
||||
@@ -19,14 +25,16 @@ COPY patches ./patches
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
|
||||
pnpm fetch && \
|
||||
pnpm fetch --ignore-scripts && \
|
||||
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||
|
||||
COPY . .
|
||||
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
||||
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
||||
# Install only from the fetched, committed lockfile so local and Docker builds
|
||||
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
|
||||
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm install --recursive
|
||||
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
ARG CI='true'
|
||||
@@ -37,7 +45,9 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
|
||||
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
|
||||
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
|
||||
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
|
||||
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
|
||||
--mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \
|
||||
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \
|
||||
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
@@ -85,5 +95,6 @@ ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
|
||||
EXPOSE 7575
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -14,8 +14,8 @@ This guide will help you set up your development environment to start contributi
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
|
||||
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
|
||||
- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/).
|
||||
- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm version.
|
||||
- **Git**: You can download it from the [official website](https://git-scm.com/).
|
||||
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
|
||||
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
|
||||
@@ -49,7 +49,10 @@ This guide will help you set up your development environment to start contributi
|
||||
|
||||
#### During development
|
||||
|
||||
- `pnpm dev`: Start the development server.
|
||||
- `pnpm dev`: Start the Next.js development server.
|
||||
- `pnpm dev:benchmark`: Run the local authenticated-board benchmark. Set `DEV_BENCHMARK_BOARD_URL` and
|
||||
`DEV_BENCHMARK_STORAGE_STATE`; readiness-only measurements require the explicit `--smoke` flag and are not
|
||||
suitable for performance claims.
|
||||
- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images.
|
||||
- `pnpm dev:cli -- build <name>`: Build the current checkout as `homarr:<name>`.
|
||||
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
|
||||
@@ -74,4 +77,5 @@ This guide will help you set up your development environment to start contributi
|
||||
|
||||
- `pnpm build`: Build the project for production.
|
||||
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
|
||||
- `docker build -t homarr .`: Build a docker image for the project.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
|
||||
@@ -25,15 +25,14 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
|
||||
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` |
|
||||
| ``PGID`` | Group ID to run the container as | Any valid group ID | ``0`` |
|
||||
| ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` |
|
||||
| ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` |
|
||||
| ``ENABLE_DNS_CACHING`` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -43,9 +42,9 @@ See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
|
||||
|
||||
The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit.
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``SECRET_ENCRYPTION_KEY`` | Secret used to encrypt secrets in database. | 64 character hex string | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ----------------------- | ------------------------------------------- | ----------------------- | ------- |
|
||||
| `SECRET_ENCRYPTION_KEY` | Secret used to encrypt secrets in database. | 64 character hex string | - |
|
||||
|
||||
:::info
|
||||
A random secret can be generated by using the following command: `openssl rand -hex 32`
|
||||
@@ -53,27 +52,27 @@ A random secret can be generated by using the following command: `openssl rand -
|
||||
|
||||
## Docker
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||
| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
||||
| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
||||
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||
|
||||
## Database
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``DB_DRIVER`` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | ``better-sqlite3`` / ``mysql2`` / ``node-postgres`` | ``better-sqlite3`` |
|
||||
| ``DB_DIALECT`` | Database dialect to use. | ``sqlite`` / ``mysql`` / ``postgresql`` | ``sqlite`` |
|
||||
| ``DB_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` |
|
||||
| ``DB_HOST`` | Database host to connect to. | Any valid database host | - |
|
||||
| ``DB_PORT`` | Database port to connect to. | Any valid database port | - |
|
||||
| ``DB_NAME`` | Database name to connect to. | Any valid database name | - |
|
||||
| ``DB_USER`` | Database user to connect with. | Any valid database user | - |
|
||||
| ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - |
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- |
|
||||
| `DB_DRIVER` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | `better-sqlite3` / `mysql2` / `node-postgres` | `better-sqlite3` |
|
||||
| `DB_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` |
|
||||
| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` |
|
||||
| `DB_HOST` | Database host to connect to. | Any valid database host | - |
|
||||
| `DB_PORT` | Database port to connect to. | Any valid database port | - |
|
||||
| `DB_NAME` | Database name to connect to. | Any valid database name | - |
|
||||
| `DB_USER` | Database user to connect with. | Any valid database user | - |
|
||||
| `DB_PASSWORD` | Database password to connect with. | Any valid database password | - |
|
||||
|
||||
You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values.
|
||||
|
||||
|
||||
## Redis
|
||||
|
||||
By default Redis is running within the installation. (for example in Docker).
|
||||
@@ -83,25 +82,23 @@ However for example for K8s it can be useful to use an external Redis instance.
|
||||
External redis is currently not supported with Proxmox Community Scripts
|
||||
:::
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
|
||||
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
|
||||
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
|
||||
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
|
||||
| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
|
||||
| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
|
||||
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------ | ------- |
|
||||
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
|
||||
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
|
||||
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
|
||||
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
|
||||
| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
|
||||
| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
|
||||
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
|
||||
|
||||
## Proxy
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| ------------------------ | ----------- | --------------- | ------- |
|
||||
| ``HTTP_PROXY`` | HTTP proxy to use | Any valid HTTP proxy URL | - |
|
||||
| ``HTTPS_PROXY`` | HTTPS proxy to use | Any valid HTTPS proxy URL | - |
|
||||
| ``NO_PROXY`` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
|
||||
|
||||
| Environment Variable | Description | Possible values | Default |
|
||||
| -------------------- | ------------------------------------------------------------------- | ------------------------- | ------- |
|
||||
| `HTTP_PROXY` | HTTP proxy to use | Any valid HTTP proxy URL | - |
|
||||
| `HTTPS_PROXY` | HTTPS proxy to use | Any valid HTTPS proxy URL | - |
|
||||
| `NO_PROXY` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
|
||||
|
||||
## Advanced deployments
|
||||
|
||||
@@ -109,6 +106,6 @@ External redis is currently not supported with Proxmox Community Scripts
|
||||
The advanced deployments environment variables should only be used if you know what you are doing.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | ----------- |
|
||||
| ``DB_MIGRATIONS_DISABLED`` | Disable db migrations. For example for helm charts |
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | -------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
|
||||
@@ -47,7 +47,6 @@ services:
|
||||
traefik.http.routers.homarr.rule: Host(`your.internal.dns.address.here.com`)
|
||||
traefik.http.routers.homarr.entrypoints: websecure
|
||||
traefik.http.routers.homarr-secure.app: homarr
|
||||
traefik.http.apps.homarr.loadbalancer.server.port: 7575
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -7,8 +7,8 @@ tags:
|
||||
- Ping
|
||||
---
|
||||
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are used in the bookmark and app widget.
|
||||
|
||||
## Create an app
|
||||
@@ -27,7 +27,7 @@ The name must be at least one character long and can be chosen freely.
|
||||
|
||||
### Icon URL
|
||||
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
|
||||
|
||||

|
||||
@@ -40,11 +40,14 @@ An optional description that can be shown as tooltip on the app widget.
|
||||
|
||||
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
|
||||
|
||||
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
|
||||
|
||||
### Ping URL
|
||||
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
|
||||
It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
|
||||
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
|
||||
|
||||
## List of apps
|
||||
|
||||
|
||||
@@ -10,118 +10,132 @@ tags:
|
||||
|
||||
# Settings
|
||||
|
||||
The settings page has a few options that will be applied to the entire Homarr application.
|
||||
They may require a full page reload for changes to apply.
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
## Analytics
|
||||
|
||||
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
|
||||
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
|
||||
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
|
||||
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
|
||||
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
|
||||
We collect this data to see what features people ACTUALLY use.
|
||||
We use this data to decide where we spend our development time.
|
||||
|
||||
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
|
||||
We do not sell the data. We do not share the data with third parties.
|
||||
The data does not contain personal information and does not identify you or your installation.
|
||||
|
||||
We understand that you might not want Homarr to communicate with our services.
|
||||
By unchecking the `Send anonymous analytics` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
|
||||
If you are paranoid, you can permanently block `umami.homarr.dev` from your DNS / firewall.
|
||||
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
|
||||
The server sends the data once a week and when the server starts.
|
||||
|
||||
Additionally, you opt in to more data collection to help us provide more useful data.
|
||||
### What we collect
|
||||
|
||||
### Integration data
|
||||
Homarr collects technical data about your installation:
|
||||
|
||||
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
- Your Homarr version
|
||||
- Your database type (SQLite, MySQL, or PostgreSQL)
|
||||
- Whether Docker or Kubernetes is enabled
|
||||
- Your enabled authentication providers
|
||||
- Your operating system and CPU architecture
|
||||
- Your default language
|
||||
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
|
||||
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
|
||||
- The widget types you use and how many you have of each
|
||||
|
||||
### Widget data
|
||||
Homarr does NOT collect:
|
||||
|
||||
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
- URLs
|
||||
- Names
|
||||
- Usernames, emails, or passwords
|
||||
- Integration secrets or API keys
|
||||
- Error messages or logs
|
||||
- Any data that can identify you
|
||||
|
||||
### Users data
|
||||
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
|
||||
|
||||
Enabling this optional data will send the count of users and whether you activated SSO.
|
||||
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
|
||||
This is disabled by default.
|
||||
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
|
||||
|
||||
### Disable analytics
|
||||
|
||||
Analytics are enabled by default. To disable analytics:
|
||||
|
||||
1. Open the Settings page.
|
||||
2. Uncheck the "Help us improve Homarr" checkbox.
|
||||
3. Reload the full page.
|
||||
|
||||
When analytics are disabled, Homarr does not send any data to PostHog.
|
||||
|
||||
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
|
||||
|
||||
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
|
||||
|
||||
## Search Engine Optimization (SEO)
|
||||
|
||||
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
|
||||
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
|
||||
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
|
||||
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
|
||||
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
|
||||
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
|
||||
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
|
||||
|
||||
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
|
||||
|
||||
### No index
|
||||
|
||||
This will ask crawlers to not index your Homarr instance on any indexes.
|
||||
It is up to the crawler to properly respect this request.
|
||||
By default, this setting is enabled. Disabling it will allow indexing.
|
||||
This setting asks crawlers to not index your instance.
|
||||
This setting is enabled by default. Disable this setting to allow indexing.
|
||||
|
||||
### No follow
|
||||
|
||||
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
|
||||
By default, this setting is enabled and will ask crawlers to not follow any links.
|
||||
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
|
||||
Crawlers follow links to discover new pages.
|
||||
This setting asks crawlers to not follow links. This setting is enabled by default.
|
||||
Crawler behavior can differ between search engines.
|
||||
|
||||
### No translate
|
||||
|
||||
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
|
||||
This can lead to some shifting and can be undesired in some cases. By enabling this,
|
||||
you ask crawlers to never translate and never prompt users for translation.
|
||||
It should be noted that some browsers will ignore this and translate regardless.
|
||||
Some browsers offer to translate pages.
|
||||
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
|
||||
|
||||
### No site links search box
|
||||
|
||||
This setting is exclusive to Google.
|
||||
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
|
||||
The user can search them directly in Google.
|
||||
You can ask the crawlers to not allow searches by enabling this setting.
|
||||
This setting is disabled by default.
|
||||
This setting applies to Google only.
|
||||
Google can show a search box below your domain.
|
||||
This setting asks Google to not show this box. This setting is disabled by default.
|
||||
|
||||
## Boards
|
||||
|
||||
### Global home board
|
||||
|
||||
You can set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
Note that the board must be public to be used as a global home board.
|
||||
You can set a global home board.
|
||||
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
|
||||
The board must be public.
|
||||
|
||||
### Global mobile board
|
||||
|
||||
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
|
||||
It will similar to the global home board be as fallback for normal users and for any anonymous users.
|
||||
It also has to be public.
|
||||
You can set a global mobile board.
|
||||
Homarr shows this board on mobile devices instead of the global home board.
|
||||
Homarr detects mobile devices with the User-Agent header.
|
||||
The board must be public.
|
||||
|
||||
### Automatic mobile layout
|
||||
|
||||
**Use automatic mobile layout** controls how every board is rendered on phones and small tablets:
|
||||
**Use automatic mobile layout** renders every board as a read-only two-column layout on phones and small tablets. Fresh Homarr installations enable this setting by default. Creating another account on an existing installation does not change it because this is a server-wide setting.
|
||||
|
||||
- Enabled: Homarr squeezes the normal widgets into a generated, read-only two-column grid. Widget content is not replaced or simplified.
|
||||
- Disabled: Homarr uses each board's saved responsive layouts and permits layout editing on mobile.
|
||||
When upgrading, Homarr enables automatic mode only if every board still has its single canonical base layout. Installations with saved responsive layouts remain in legacy mode, and an explicit saved choice is never overwritten. Turning automatic mode off restores the saved responsive layouts and mobile editing.
|
||||
|
||||
The setting is enabled by default for new instances. During upgrade, Homarr leaves it disabled when existing custom responsive layouts are detected. Saved responsive layouts are preserved while automatic mode is enabled.
|
||||
While editing a board on desktop, use the floating **Mobile preview** control to inspect the automatic item order and sizing without loading every widget a second time.
|
||||
|
||||
### App status
|
||||
|
||||
#### Enable status by default
|
||||
|
||||
You can decide if the app status should be enabled by default when adding a new app to the board.
|
||||
You can decide if app status is enabled when you add a new app.
|
||||
|
||||
#### Force disable status
|
||||
|
||||
You can decide if the app status should be disabled for all boards.
|
||||
|
||||
###
|
||||
You can decide if app status is disabled on all boards.
|
||||
|
||||
## Appearance
|
||||
|
||||
### Default color scheme
|
||||
|
||||
You can set the default color scheme for all users that have not set a color scheme themselves.
|
||||
You can set the default color scheme for users without their own color scheme.
|
||||
|
||||
## Culture
|
||||
|
||||
### Default language
|
||||
|
||||
You can set the default language for all users that have not set a language themselves.
|
||||
You can set the default language for users without their own language.
|
||||
+22
-11
@@ -1,4 +1,6 @@
|
||||
// Importing env files here to validate on build
|
||||
import path from "node:path";
|
||||
|
||||
import "@homarr/auth/env";
|
||||
import "@homarr/core/infrastructure/db/env";
|
||||
import "@homarr/common/env";
|
||||
@@ -13,7 +15,21 @@ const withNextIntl = createNextIntlPlugin({
|
||||
requestConfig: "../../packages/translation/src/request.ts",
|
||||
});
|
||||
|
||||
const getDevelopmentServiceAliases = () => {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
"@homarr/tasks": "./src/instrumentation-noop.ts",
|
||||
"@homarr/websocket": "./src/instrumentation-noop.ts",
|
||||
};
|
||||
};
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Next previews otherwise create agent instruction files in the application
|
||||
// directory during development.
|
||||
agentRules: false,
|
||||
env: {
|
||||
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
||||
},
|
||||
@@ -30,19 +46,14 @@ const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
webpackMemoryOptimizations: true,
|
||||
turbopackFileSystemCacheForBuild: true,
|
||||
useTypeScriptCli: true,
|
||||
},
|
||||
turbopack: {
|
||||
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
|
||||
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
|
||||
// (see vercel/next.js#95125). Suppress until upstream fix lands.
|
||||
ignoreIssue: [
|
||||
{
|
||||
path: "**/*",
|
||||
title: "Encountered unexpected file in NFT list",
|
||||
},
|
||||
],
|
||||
root: path.resolve(import.meta.dirname, "../.."),
|
||||
// Development runs tasks and WebSocket as separate processes. These aliases
|
||||
// keep their production-only instrumentation imports out of the dev graph.
|
||||
resolveAlias: getDevelopmentServiceAliases(),
|
||||
},
|
||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||
images: {
|
||||
|
||||
@@ -59,11 +59,10 @@
|
||||
"@modelcontextprotocol/sdk": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/query-async-storage-persister": "catalog:",
|
||||
"@tanstack/react-devtools": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
"@tanstack/react-query-next-experimental": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/next": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
|
||||
|
||||
const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const;
|
||||
const metricStyle = {
|
||||
background: "color-mix(in srgb, currentColor 4%, transparent)",
|
||||
border: "1px solid color-mix(in srgb, currentColor 18%, transparent)",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
} as const;
|
||||
|
||||
const readDiagnostics = (queryClient: QueryClient) => {
|
||||
const queries = queryClient.getQueryCache().getAll();
|
||||
const mutations = queryClient.getMutationCache().getAll();
|
||||
const board = document.querySelector("[data-homarr-dev-benchmark-board]");
|
||||
const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0;
|
||||
const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0;
|
||||
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
|
||||
|
||||
return {
|
||||
active: queries.filter((query) => query.getObserversCount() > 0).length,
|
||||
errors: queries.filter((query) => query.state.status === "error").length,
|
||||
fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length,
|
||||
pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length,
|
||||
stale: queries.filter((query) => query.isStale()).length,
|
||||
total: queries.length,
|
||||
readyWidgets,
|
||||
widgetItems,
|
||||
scriptKiB: Math.round(
|
||||
resources
|
||||
.filter((resource) => resource.initiatorType === "script")
|
||||
.reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }: { label: string; value: number | string }) => (
|
||||
<div style={metricStyle}>
|
||||
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
|
||||
<output style={{ ...valueStyle, display: "block", fontSize: 22, fontWeight: 600, marginTop: 4 }}>{value}</output>
|
||||
</div>
|
||||
);
|
||||
|
||||
const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => {
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient));
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const update = () => setDiagnostics(readDiagnostics(queryClient));
|
||||
let frame: number | undefined;
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== undefined) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = undefined;
|
||||
update();
|
||||
});
|
||||
};
|
||||
const unsubscribeQueries = queryClient.getQueryCache().subscribe(scheduleUpdate);
|
||||
const unsubscribeMutations = queryClient.getMutationCache().subscribe(scheduleUpdate);
|
||||
const observer = new MutationObserver(scheduleUpdate);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
update();
|
||||
|
||||
return () => {
|
||||
unsubscribeQueries();
|
||||
unsubscribeMutations();
|
||||
observer.disconnect();
|
||||
if (frame !== undefined) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [active, pathname, queryClient]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<section data-homarr-devtools-panel style={{ fontFamily: "system-ui, sans-serif", padding: 16 }}>
|
||||
<header style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>Homarr performance</h2>
|
||||
<p style={{ marginBlock: "4px 0", opacity: 0.7 }}>Live state for {pathname}</p>
|
||||
</header>
|
||||
<div style={{ display: "grid", gap: 8, gridTemplateColumns: "repeat(auto-fit, minmax(110px, 1fr))" }}>
|
||||
<Metric label="Queries" value={diagnostics.total} />
|
||||
<Metric label="Active" value={diagnostics.active} />
|
||||
<Metric label="Fetching" value={diagnostics.fetching} />
|
||||
<Metric label="Stale" value={diagnostics.stale} />
|
||||
<Metric label="Errors" value={diagnostics.errors} />
|
||||
<Metric label="Mutations" value={diagnostics.pendingMutations} />
|
||||
<Metric label="Widgets ready" value={`${diagnostics.readyWidgets}/${diagnostics.widgetItems}`} />
|
||||
<Metric label="Script KiB" value={diagnostics.scriptKiB} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const developmentToolsPlugins = [
|
||||
{
|
||||
id: "tanstack-query",
|
||||
name: "TanStack Query",
|
||||
render: () => <ReactQueryDevtoolsPanel />,
|
||||
},
|
||||
{
|
||||
id: "homarr-performance",
|
||||
name: "Homarr",
|
||||
defaultOpen: true,
|
||||
render: (_element, { devtoolsOpen }) => <HomarrDevtoolsPanel active={devtoolsOpen} />,
|
||||
},
|
||||
] satisfies TanStackDevtoolsReactPlugin[];
|
||||
|
||||
export const DevelopmentTools = () => (
|
||||
<TanStackDevtools config={{ position: "bottom-right", hideUntilHover: true }} plugins={developmentToolsPlugins} />
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
|
||||
|
||||
const queryCacheStorage = {
|
||||
getItem: (_key: string) => null as string | null,
|
||||
async setItem(_key: string, value: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
|
||||
console.warn("[query-cache] persist failed", error);
|
||||
});
|
||||
},
|
||||
async removeItem(_key: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
|
||||
console.warn("[query-cache] remove failed", error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const createWidgetQueryPersister = () =>
|
||||
createAsyncStoragePersister({
|
||||
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
||||
key: queryCacheStoragePrefix,
|
||||
throttleTime: 2000,
|
||||
serialize: (data) => superjson.stringify(data),
|
||||
deserialize: (data) => superjson.parse(data),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const createTrpcError = (code: string) => {
|
||||
const error = Object.assign(new Error(code), { data: { code } });
|
||||
Object.setPrototypeOf(error, TRPCClientError.prototype);
|
||||
return error;
|
||||
};
|
||||
|
||||
describe("createQueryRetry", () => {
|
||||
it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])(
|
||||
"does not retry the permanent %s response",
|
||||
(code) => {
|
||||
expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("retries transient and network failures only up to the configured limit", () => {
|
||||
const retry = createQueryRetry(3);
|
||||
|
||||
expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true);
|
||||
expect(retry(2, new Error("connection reset"))).toBe(true);
|
||||
expect(retry(3, new Error("connection reset"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
const nonRetryableTrpcCodes = new Set([
|
||||
"PARSE_ERROR",
|
||||
"BAD_REQUEST",
|
||||
"UNAUTHORIZED",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"METHOD_NOT_SUPPORTED",
|
||||
"CONFLICT",
|
||||
"PRECONDITION_FAILED",
|
||||
"PAYLOAD_TOO_LARGE",
|
||||
"UNPROCESSABLE_CONTENT",
|
||||
]);
|
||||
|
||||
export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => {
|
||||
if (failureCount >= maxRetries) return false;
|
||||
if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
|
||||
describe("getSessionQueryScope", () => {
|
||||
it("is stable when permission order changes", () => {
|
||||
expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe(
|
||||
getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes for a different user or permission set", () => {
|
||||
const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } });
|
||||
|
||||
expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original);
|
||||
expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original);
|
||||
expect(getSessionQueryScope(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes the previous session subtree before requesting a document reload", async () => {
|
||||
const host = document.createElement("div");
|
||||
const root = createRoot(host);
|
||||
const onScopeChange = vi.fn();
|
||||
const renderScope = async (currentScope: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
SessionQueryScopeGuard,
|
||||
{ currentScope, initialScope: "a", onScopeChange },
|
||||
createElement("span", null, "session-a-secret"),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await renderScope("a");
|
||||
expect(host.textContent).toBe("session-a-secret");
|
||||
|
||||
await renderScope("b");
|
||||
expect(host.textContent).not.toContain("session-a-secret");
|
||||
expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true");
|
||||
expect(onScopeChange).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createElement, useEffect } from "react";
|
||||
|
||||
interface QuerySession {
|
||||
user: {
|
||||
id: string;
|
||||
permissions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const getSessionQueryScope = (session: QuerySession | null | undefined) =>
|
||||
session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null;
|
||||
|
||||
interface SessionQueryScopeGuardProps {
|
||||
initialScope: string | null;
|
||||
currentScope: string | null;
|
||||
onScopeChange: () => void;
|
||||
}
|
||||
|
||||
export const SessionQueryScopeGuard = ({
|
||||
initialScope,
|
||||
currentScope,
|
||||
onScopeChange,
|
||||
children,
|
||||
}: PropsWithChildren<SessionQueryScopeGuardProps>) => {
|
||||
const hasScopeChanged = initialScope !== currentScope;
|
||||
useEffect(() => {
|
||||
if (hasScopeChanged) onScopeChange();
|
||||
}, [hasScopeChanged, onScopeChange]);
|
||||
|
||||
if (!hasScopeChanged) return children;
|
||||
|
||||
return createElement("div", {
|
||||
"aria-busy": true,
|
||||
"aria-label": "Refreshing session",
|
||||
role: "status",
|
||||
style: { minHeight: "100dvh" },
|
||||
});
|
||||
};
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
@@ -25,18 +23,24 @@ import { TRPCClientError } from "@trpc/client";
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
isPersistableWidgetQueryKey,
|
||||
queryCacheBuster,
|
||||
dashboardSupportingQueryPolicies,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
} from "@homarr/api/query-cache";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
process.env.NODE_ENV === "development"
|
||||
? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false })
|
||||
: () => null;
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -59,21 +63,46 @@ const constructWebsocketUrl = () => {
|
||||
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
||||
};
|
||||
|
||||
const wsClient = createWSClient({
|
||||
url: constructWebsocketUrl(),
|
||||
});
|
||||
export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
|
||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
const [persister] = useState(() => createWidgetQueryPersister());
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
initialScope={initialSessionQueryScope}
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={reloadPage}
|
||||
>
|
||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
url: constructWebsocketUrl(),
|
||||
lazy: { enabled: true, closeMs: 30_000 },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
void wsClient.close();
|
||||
},
|
||||
[wsClient],
|
||||
);
|
||||
const [queryClient] = useState(() => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
gcTime: queryCacheDefaultGcTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 3,
|
||||
retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3),
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
@@ -91,23 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
},
|
||||
},
|
||||
});
|
||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
||||
for (const { definition } of Object.values(widgetImports)) {
|
||||
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
|
||||
if (def.refetchInterval === undefined) continue;
|
||||
const key = def.queryKey ?? [["widget", def.kind]];
|
||||
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
||||
client.setQueryDefaults(key, { refetchInterval: interval });
|
||||
client.setQueryDefaults([["widget"]], {
|
||||
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
||||
client.setQueryDefaults(queryKey, {
|
||||
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
||||
});
|
||||
}
|
||||
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
|
||||
client.setQueryDefaults(queryKey, policy);
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
const [trpcClient] = useState(() => {
|
||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||
|
||||
const trpcClient = useMemo(() => {
|
||||
return clientApi.createClient({
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
enabled: (opts) => opts.direction === "down" && opts.result instanceof Error,
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
@@ -147,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
}, [wsClient]);
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
buster: queryCacheBuster,
|
||||
maxAge: queryCacheDefaultGcTimeMs,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</PersistQueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</QueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string }>({
|
||||
async getInitialBoardAsync() {
|
||||
return await api.board.getHomeBoard();
|
||||
return await getHomeBoardAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { createBoardContentPage } from "../../_creator";
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardAddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: { id: "new", name: "" },
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{ title: (translate) => translate("section.category.create.title") },
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal()}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconBox size={20} />}
|
||||
onClick={() =>
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ export const ClientBoard = () => {
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
{isMobile ? (
|
||||
<MobileBoard />
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { cache } from "react";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Placed here because gridstack styles are used for board content
|
||||
import "~/styles/gridstack.scss";
|
||||
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import superjson from "superjson";
|
||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
||||
import { getQueryClient } from "@homarr/api/server";
|
||||
import { makeQueryClient } from "@homarr/api/shared";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||
@@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getQueryCacheAsync } from "@homarr/redis";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
@@ -29,6 +25,7 @@ import { DynamicClientBoard } from "./_dynamic-client";
|
||||
import { BoardContentHeaderActions } from "./_header-actions";
|
||||
|
||||
const logger = createLogger({ module: "createBoardContentPage" });
|
||||
const getQueryClient = cache(makeQueryClient);
|
||||
|
||||
export type Params = Record<string, unknown>;
|
||||
|
||||
@@ -61,7 +58,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}
|
||||
return acc;
|
||||
}, new Map<WidgetKind, Item[]>());
|
||||
|
||||
const [integrations] = await Promise.all([
|
||||
getIntegrationsWithPermissionsAsync(session),
|
||||
...Array.from(itemsMap).map(([kind, items]) =>
|
||||
@@ -77,19 +73,12 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
),
|
||||
]);
|
||||
|
||||
const userId = session?.user.id ?? "anonymous";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense>
|
||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
||||
</Suspense>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
</>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
@@ -120,19 +109,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
|
||||
try {
|
||||
const serialized = await getQueryCacheAsync(userId, boardId);
|
||||
if (!serialized) return null;
|
||||
|
||||
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
|
||||
if (!persisted || persisted.buster !== queryCacheBuster) return null;
|
||||
if (!persisted.clientState?.queries?.length) return null;
|
||||
|
||||
return <HydrationBoundary state={persisted.clientState} />;
|
||||
} catch (error) {
|
||||
logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { SimpleGrid, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
const BoardLoadingShell = () => (
|
||||
<Stack h="100%" p="md" aria-busy aria-label="Loading dashboard" role="status">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{[2, 1, 2, 2, 1, 2].map((rows, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
data-homarr-dev-benchmark-board-shell
|
||||
height={rows * 96}
|
||||
radius="var(--mantine-radius-default)"
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||
ssr: false,
|
||||
loading: BoardLoadingShell,
|
||||
});
|
||||
@@ -1,46 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
IconBoxAlignTop,
|
||||
IconChevronDown,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconResize,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
|
||||
const loadBoardAddMenu = () => import("./_board-add-menu");
|
||||
const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined);
|
||||
const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), {
|
||||
loading: () => (
|
||||
<HeaderButton loading>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
),
|
||||
});
|
||||
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
@@ -54,16 +45,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditMode && !isMobile && <AddMenu />}
|
||||
{isEditMode && !isMobile && <BoardAddMenu />}
|
||||
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} hidden={isMobile} />
|
||||
|
||||
{!demoReadOnly && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<TourTarget id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
@@ -71,94 +62,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
);
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(() => {
|
||||
openItemSelectModal();
|
||||
}, [openItemSelectModal]);
|
||||
|
||||
const handleSelectApp = useCallback(() => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
const handleAddIntegration = useCallback(() => {
|
||||
openIntegrationSelectModal({});
|
||||
}, [openIntegrationSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
@@ -188,7 +91,7 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
|
||||
}, [utils, board.name, close]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (hidden) return;
|
||||
if (hidden && !isEditMode) return;
|
||||
if (isEditMode) {
|
||||
if (demoReadOnly) return discardDemoChanges();
|
||||
return saveBoard(board);
|
||||
@@ -199,24 +102,33 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
if (hidden) return null;
|
||||
if (hidden && !isEditMode) return null;
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<HeaderButton onClick={toggle} loading={isPending} aria-label={t("label")}>
|
||||
<TourTarget id="board-edit-mode">
|
||||
<HeaderButton
|
||||
onClick={toggle}
|
||||
loading={isPending}
|
||||
aria-label={t("label")}
|
||||
onFocus={preloadBoardAddMenu}
|
||||
onPointerEnter={preloadBoardAddMenu}
|
||||
>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<TourTarget id="board-switcher">
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
@@ -224,6 +136,11 @@ const SelectBoardsMenu = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{isPending && (
|
||||
<Center p="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
@@ -238,7 +155,7 @@ const SelectBoardsMenu = () => {
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</OnboardingTour.Target>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -251,15 +168,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
const target = (event.target as HTMLElement).closest("a");
|
||||
|
||||
if (!target) {
|
||||
console.warn("No anchor element found for click event", event);
|
||||
return;
|
||||
}
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
@@ -285,19 +200,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
event.returnValue = true;
|
||||
};
|
||||
|
||||
const anchors = document.querySelectorAll(anchorSelector);
|
||||
anchors.forEach((link) => {
|
||||
link.addEventListener("click", handleClick);
|
||||
});
|
||||
document.addEventListener("click", handleClick, true);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
anchors.forEach((link) => {
|
||||
link.removeEventListener("click", handleClick);
|
||||
});
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty]);
|
||||
}, [isDirty, openConfirmModal, router, t]);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getBoardByNameAsync } from "@homarr/api/board-server";
|
||||
|
||||
import { BoardOtherHeaderActions } from "../_header-actions";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
|
||||
export default createBoardLayout<{ locale: string; name: string }>({
|
||||
headerActions: <BoardOtherHeaderActions />,
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
return await getBoardByNameAsync(name);
|
||||
},
|
||||
});
|
||||
@@ -4,17 +4,19 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { userAgent } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { MobileBoardViewportProvider } from "~/components/board/use-mobile-board";
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { MobileBoardViewportProvider } from "~/components/board/use-mobile-board";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
||||
import { env } from "~/env";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
@@ -24,11 +26,6 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
const logger = createLogger({ module: "createBoardLayout" });
|
||||
|
||||
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
|
||||
if (!hasSession) return <>{children}</>;
|
||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
||||
};
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
@@ -46,8 +43,34 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}: PropsWithChildren<{
|
||||
params: Promise<TParams>;
|
||||
}>) => {
|
||||
const session = await auth();
|
||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
||||
const resolvedParams = await params;
|
||||
const sessionPromise = auth();
|
||||
const initialBoardPromise = getInitialBoard(resolvedParams).then(
|
||||
(board) => ({ status: "fulfilled", board }) as const,
|
||||
(error: unknown) => ({ status: "rejected", error }) as const,
|
||||
);
|
||||
const colorSchemePromise = getCurrentColorSchemeAsync();
|
||||
const requestHeadersPromise = headers();
|
||||
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
|
||||
if (!withTour || !session || env.DEMO_MODE) return false;
|
||||
|
||||
try {
|
||||
const user = await getRscUserSettingsAsync(session.user.id);
|
||||
return user !== undefined && !user.completedBoardTour;
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to load the board tour status", { cause: error }));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [session, initialBoardResult, colorScheme, requestHeaders, shouldRunBoardTour] = await Promise.all([
|
||||
sessionPromise,
|
||||
initialBoardPromise,
|
||||
colorSchemePromise,
|
||||
requestHeadersPromise,
|
||||
shouldRunBoardTourPromise,
|
||||
]);
|
||||
if (initialBoardResult.status === "rejected") {
|
||||
const { error } = initialBoardResult;
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (!session) {
|
||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||
@@ -63,8 +86,8 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
const [colorScheme, requestHeaders] = await Promise.all([getCurrentColorSchemeAsync(), headers()]);
|
||||
}
|
||||
const initialBoard = initialBoardResult.board;
|
||||
const deviceType = userAgent(new Headers(requestHeaders)).device.type;
|
||||
const initialIsMobile = deviceType === "mobile" || deviceType === "tablet";
|
||||
|
||||
@@ -75,7 +98,7 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
@@ -84,7 +107,7 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
</BoardTourGate>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { JSX } from "react";
|
||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { db } from "@homarr/db";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
@@ -29,9 +29,16 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
||||
finish: InitFinish,
|
||||
};
|
||||
|
||||
const getCurrentOnboardingStepAsync = async () => {
|
||||
const value = await db.query.onboarding.findFirst();
|
||||
if (!value) return { current: "start" as const, previous: null };
|
||||
|
||||
return { current: value.step, previous: value.previousStep };
|
||||
};
|
||||
|
||||
export default async function InitPage() {
|
||||
const t = await getScopedI18n("init.step");
|
||||
const currentStep = await api.onboard.currentStep();
|
||||
const currentStep = await getCurrentOnboardingStepAsync();
|
||||
|
||||
const CurrentComponent = stepComponents[currentStep.current];
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ import { notFound } from "next/navigation";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { SettingsProvider } from "@homarr/settings";
|
||||
@@ -43,6 +43,8 @@ const fontSans = Inter({
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const logger = createLogger({ module: "rootLayout" });
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
@@ -78,15 +80,27 @@ export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: SupportedLanguage }>;
|
||||
}) {
|
||||
if (!isLocaleSupported((await props.params).locale)) {
|
||||
const { locale } = await props.params;
|
||||
if (!isLocaleSupported(locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
||||
const serverSettings = await getServerSettingsAsync(db);
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||
const sessionPromise = auth();
|
||||
const userPromise = sessionPromise.then((session) =>
|
||||
session
|
||||
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
|
||||
return null;
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const [session, user, serverSettings, colorScheme] = await Promise.all([
|
||||
sessionPromise,
|
||||
userPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
]);
|
||||
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
(innerProps) => {
|
||||
@@ -126,8 +140,6 @@ export default async function Layout(props: {
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
const { locale } = await props.params;
|
||||
|
||||
return (
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { env } from "@homarr/docker/env";
|
||||
@@ -39,11 +41,29 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
|
||||
import { env as nextEnv } from "~/env";
|
||||
|
||||
const logger = createLogger({ module: "manageLayout" });
|
||||
|
||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
const t = await getScopedI18n("management.navbar");
|
||||
const session = await auth();
|
||||
const sessionPromise = auth();
|
||||
const shouldRunManageTourPromise = sessionPromise.then(async (session) => {
|
||||
if (!session || nextEnv.DEMO_MODE) return false;
|
||||
|
||||
try {
|
||||
const user = await getRscUserSettingsAsync(session.user.id);
|
||||
return user !== undefined && !user.completedManageTour;
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to load the management tour status", { cause: error }));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [t, session, shouldRunManageTour] = await Promise.all([
|
||||
getScopedI18n("management.navbar"),
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
@@ -225,7 +245,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
if (!session) return shell;
|
||||
|
||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
||||
return (
|
||||
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
|
||||
{shell}
|
||||
</ManageTourGate>
|
||||
);
|
||||
}
|
||||
+3
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
@@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const ResetTours = () => {
|
||||
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
||||
@@ -18,6 +20,7 @@ export const ResetTours = () => {
|
||||
message: t("resetSuccess.message"),
|
||||
});
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { use, useCallback, useState } from "react";
|
||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
|
||||
import type { Dimensions } from "./_dimension-modal";
|
||||
import { PreviewDimensionsModal } from "./_dimension-modal";
|
||||
|
||||
interface WidgetPreviewPageContentProps {
|
||||
kind: WidgetKind;
|
||||
integrationData: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind: IntegrationKind;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
|
||||
export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps) => {
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
|
||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||
width: 128,
|
||||
height: 128,
|
||||
@@ -45,7 +42,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
integrationIds: string[];
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}>({
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
||||
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -54,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenEditWidgetModal = useCallback(() => {
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
value: state,
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
const handleOpenEditWidgetModal = useCallback(async () => {
|
||||
const hasIntegrationSupport = "supportedIntegrations" in currentDefinition;
|
||||
setIsEditorLoading(true);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
definition: currentDefinition,
|
||||
value: state,
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
},
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
||||
),
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`;
|
||||
{
|
||||
title(translate) {
|
||||
return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
||||
|
||||
const Comp = loadWidgetDynamic(kind);
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
@@ -94,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
});
|
||||
}, [dimensions, openPreviewDimensionsModal]);
|
||||
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
setState({ ...state, options: { ...state.options, newOptions } });
|
||||
const updateOptions = useCallback(
|
||||
({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -105,10 +115,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
>
|
||||
<Comp
|
||||
<Component
|
||||
options={state.options as never}
|
||||
integrationIds={state.integrationIds}
|
||||
width={dimensions.width}
|
||||
@@ -127,7 +137,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={handleOpenEditWidgetModal}
|
||||
onClick={() => void handleOpenEditWidgetModal()}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
loading={isEditorLoading}
|
||||
aria-label={t("common.action.edit")}
|
||||
>
|
||||
<IconPencil size={24} />
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetKinds } from "@homarr/definitions";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
const getLinks = () => {
|
||||
return Object.entries(widgetImports).map(([key, value]) => {
|
||||
return {
|
||||
href: `/widgets/${key}`,
|
||||
icon: value.definition.icon,
|
||||
label: value.definition.kind,
|
||||
};
|
||||
});
|
||||
};
|
||||
const links = widgetKinds.map((kind) => ({
|
||||
href: `/widgets/${kind}`,
|
||||
icon: widgetCatalogIcons[kind],
|
||||
label: kind,
|
||||
}));
|
||||
|
||||
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
||||
const links = getLinks();
|
||||
|
||||
return (
|
||||
<ClientShell hasHeader={false}>
|
||||
<MainNavigation links={links} />
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Suspense } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Center } from "@mantine/core";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WidgetPreviewPageContent } from "./_content";
|
||||
|
||||
@@ -13,24 +12,16 @@ interface Props {
|
||||
}
|
||||
|
||||
export default async function WidgetPreview(props: Props) {
|
||||
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
|
||||
const { kind } = await props.params;
|
||||
if (!widgetKinds.includes(kind as WidgetKind)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const integrationData = await db.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
kind: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sort = (await props.params).kind as WidgetKind;
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
|
||||
<Suspense fallback={<Loader size="sm" />}>
|
||||
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||
</Suspense>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import type { ComponentType, MutableRefObject } from "react";
|
||||
import { Suspense, use, useRef } from "react";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Card, Center, Loader } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
@@ -19,7 +23,7 @@ import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
@@ -31,6 +35,53 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type BoardItemCardProps = CardProps & {
|
||||
item: SectionItem;
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...cardProps}
|
||||
ref={innerRef}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetDefinitionLoadError = ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
item,
|
||||
innerRef,
|
||||
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<Center h="100%">
|
||||
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
);
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
@@ -38,29 +89,28 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<BoardItemCard item={item} innerRef={ref}>
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
}
|
||||
>
|
||||
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
||||
</Card>
|
||||
</WidgetContextMenu>
|
||||
<LoadedBoardItemContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
innerRef={ref}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -86,40 +136,72 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
|
||||
return (
|
||||
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
|
||||
</ErrorBoundary>
|
||||
</BoardItemCard>
|
||||
</WidgetContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
definition: WidgetDefinition;
|
||||
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
||||
const queryClient = useQueryClient();
|
||||
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
<BoardItemMenu
|
||||
offset={4}
|
||||
item={newItem}
|
||||
definition={definition}
|
||||
resetErrorBoundary={resetErrorBoundary}
|
||||
/>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -131,23 +213,25 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} definition={definition} />
|
||||
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
|
||||
<Component
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
@@ -8,39 +8,44 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
offset: number;
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
resetErrorBoundary?: () => void;
|
||||
}
|
||||
|
||||
export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
if (!session) return null;
|
||||
const [isEditMode] = useEditMode();
|
||||
if (!session || !isEditMode) return null;
|
||||
|
||||
return <BoardItemMenuInner {...props} />;
|
||||
};
|
||||
|
||||
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const hasSupportedIntegrations =
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: isMenuOpen && hasSupportedIntegrations,
|
||||
});
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const settings = useSettings();
|
||||
|
||||
@@ -52,12 +57,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
}
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
if (!isEditMode || isPending) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
@@ -80,10 +84,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
@@ -107,7 +111,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -123,11 +127,19 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
disabled={hasSupportedIntegrations && isPending}
|
||||
>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onFocus={preloadItemMoveModal}
|
||||
onPointerEnter={preloadItemMoveModal}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
|
||||
@@ -9,13 +9,13 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface InnerProps {
|
||||
export interface ItemMoveModalProps {
|
||||
gridStack: GridStack;
|
||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||
const form = useZodForm(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
|
||||
describe("widget selection lock", () => {
|
||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||
const lock = { current: false };
|
||||
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
expect(tryLockSelection(lock)).toBe(false);
|
||||
|
||||
unlockSelection(lock);
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatchingIntegrationsAsync", () => {
|
||||
it("waits for the shared integration query before a cold integration-backed selection", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [
|
||||
{ id: "matching", kind: "sonarr" as const },
|
||||
{ id: "other", kind: "radarr" as const },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not wait for integrations when the widget has no integration support", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: [],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
export const tryLockSelection = (lock: { current: boolean }) => {
|
||||
if (lock.current) return false;
|
||||
lock.current = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const unlockSelection = (lock: { current: boolean }) => {
|
||||
lock.current = false;
|
||||
};
|
||||
|
||||
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations,
|
||||
currentData,
|
||||
ensureDataAsync,
|
||||
}: {
|
||||
hasIntegrationSupport: boolean;
|
||||
supportedIntegrations: readonly IntegrationKind[];
|
||||
currentData: TIntegration[] | undefined;
|
||||
ensureDataAsync: () => Promise<TIntegration[]>;
|
||||
}) => {
|
||||
if (!hasIntegrationSupport) return [];
|
||||
|
||||
const integrations = currentData ?? (await ensureDataAsync());
|
||||
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
.action {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.card:hover .action,
|
||||
.card:focus-within .action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,65 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import classes from "./item-select-modal.module.css";
|
||||
|
||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
interface ItemSelectModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: void;
|
||||
integrationData: RouterOutputs["integration"]["all"] | undefined;
|
||||
customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined;
|
||||
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
|
||||
}
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
integrationData,
|
||||
customWidgetDefs,
|
||||
ensureIntegrationDataAsync,
|
||||
}: ItemSelectModalContentProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
objectEntries(widgetImports)
|
||||
.filter(([kind]) => kind !== "customApi")
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
supportedIntegrations:
|
||||
"supportedIntegrations" in value.definition
|
||||
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
||||
: [],
|
||||
icon: value.definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
.map((kind) => {
|
||||
return {
|
||||
kind,
|
||||
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
|
||||
(integration) => integration !== "mock",
|
||||
),
|
||||
icon: widgetCatalogIcons[kind],
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
};
|
||||
})
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.kind === "app") return -1;
|
||||
if (itemB.kind === "app") return 1;
|
||||
@@ -66,52 +85,81 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
[customWidgetDefs, search],
|
||||
);
|
||||
|
||||
const handleAddCustomWidget = (definitionId: string) => {
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||
actions.closeModal();
|
||||
const notifyDefinitionLoadError = (error: unknown) => {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
const handleAddCustomWidget = async (definitionId: string) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(`custom:${definitionId}`);
|
||||
try {
|
||||
const definition = await loadWidgetDefinition("customApi");
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||
actions.closeModal();
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
const matchingIntegrations = hasIntegrationSupport
|
||||
? (integrationData ?? []).filter((integration) =>
|
||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||
)
|
||||
: [];
|
||||
const handleAdd = async (kind: WidgetKind) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(kind);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
||||
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations: definition.supportedIntegrations ?? [],
|
||||
currentData: integrationData,
|
||||
ensureDataAsync: ensureIntegrationDataAsync,
|
||||
});
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -120,9 +168,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -130,7 +178,13 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => handleAdd(item.kind)}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onIntent={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||
/>
|
||||
))}
|
||||
@@ -146,12 +200,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
style={{ overflow: "hidden" }}
|
||||
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -169,18 +223,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
|
||||
<Button
|
||||
onClick={() => void handleAddCustomWidget(def.id)}
|
||||
variant="light"
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === `custom:${def.id}`}
|
||||
>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -196,7 +256,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
}).withOptions({
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = (props: Pick<ItemSelectModalContentProps, "actions" | "innerProps">) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
|
||||
return (
|
||||
<ItemSelectModalContent
|
||||
{...props}
|
||||
integrationData={integrationData}
|
||||
customWidgetDefs={customWidgetDefs}
|
||||
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFrame {...props} />).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -204,6 +281,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onIntent,
|
||||
disabled,
|
||||
loading,
|
||||
hasMatchingIntegration,
|
||||
}: {
|
||||
item: {
|
||||
@@ -214,23 +294,27 @@ const WidgetItem = ({
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onIntent: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
hasMatchingIntegration: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
"--_hover-opacity": "0",
|
||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -247,18 +331,17 @@ const WidgetItem = ({
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
import type { ItemMoveModalProps } from "./item-move-modal";
|
||||
|
||||
let itemMoveModalPromise: Promise<typeof import("./item-move-modal")> | undefined;
|
||||
|
||||
const loadItemMoveModal = () => {
|
||||
if (itemMoveModalPromise) return itemMoveModalPromise;
|
||||
|
||||
const currentPromise = import("./item-move-modal");
|
||||
itemMoveModalPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined);
|
||||
|
||||
interface LazyItemMoveModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: ItemMoveModalProps;
|
||||
}
|
||||
|
||||
const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => {
|
||||
const { ItemMoveModal } = use(loadItemMoveModal());
|
||||
const Component = ItemMoveModal.component;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
export const LazyItemMoveModal = createModal<ItemMoveModalProps>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
itemMoveModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyItemMoveModalContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("item.moveResize.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
|
||||
|
||||
export const loadWidgetEditModal = () => {
|
||||
if (widgetEditModalPromise) return widgetEditModalPromise;
|
||||
|
||||
const currentPromise = import("@homarr/widgets/modals");
|
||||
widgetEditModalPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||
|
||||
interface LazyWidgetEditModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: WidgetEditModalProps<WidgetKind>;
|
||||
}
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const Component = WidgetEditModal.component;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
widgetEditModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyWidgetEditModalContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
keepMounted: true,
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -25,63 +25,116 @@ import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const settings = useSettings();
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
|
||||
{children}
|
||||
</WidgetContextMenuInner>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetContextMenuInner = ({
|
||||
item,
|
||||
definition,
|
||||
widgetStateRef,
|
||||
children,
|
||||
settings,
|
||||
}: WidgetContextMenuProps & { settings: ReturnType<typeof useSettings> }) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={300}
|
||||
closeOnItemClick={false}
|
||||
position="right-start"
|
||||
offset={4}
|
||||
opened={isMenuOpen}
|
||||
onChange={setIsMenuOpen}
|
||||
>
|
||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||
<Menu.Dropdown>
|
||||
{isMenuOpen && (
|
||||
<WidgetContextMenuDropdown
|
||||
item={item}
|
||||
definition={definition}
|
||||
widgetStateRef={widgetStateRef}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> & {
|
||||
settings: ReturnType<typeof useSettings>;
|
||||
};
|
||||
|
||||
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const tItem = useScopedI18n("item");
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const hasSupportedIntegrations =
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
});
|
||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const widgetQueryKey = useMemo(
|
||||
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
|
||||
[currentDefinition, item.kind],
|
||||
);
|
||||
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
const isWidgetFetching =
|
||||
useIsFetching({
|
||||
predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
|
||||
}) > 0;
|
||||
const handleRefetch = useCallback(() => {
|
||||
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
||||
}, [queryClient, widgetQueryKey]);
|
||||
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" })));
|
||||
}, [queryClient, widgetQueryKeys]);
|
||||
|
||||
const options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||
[item.kind, settings, item.options],
|
||||
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
|
||||
[definition, settings, item.options],
|
||||
);
|
||||
|
||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||
const toggleOptions = useMemo(() => {
|
||||
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
||||
}, [currentDefinition, settings]);
|
||||
}, [definition, settings]);
|
||||
|
||||
const widgetContextActions = useMemo(() => {
|
||||
const def = currentDefinition as Record<string, unknown>;
|
||||
const def = definition as unknown as Record<string, unknown>;
|
||||
if (typeof def.contextActions !== "function") return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actions = (def.contextActions as any)({
|
||||
@@ -94,7 +147,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
widgetStateRef,
|
||||
});
|
||||
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
||||
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
||||
}, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
||||
|
||||
const persistBoard = useCallback(
|
||||
(updatedItems: typeof board.items) => {
|
||||
@@ -107,6 +160,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
@@ -133,10 +187,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
@@ -150,7 +204,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
integrationData,
|
||||
currentDefinition,
|
||||
definition,
|
||||
settings,
|
||||
isEditMode,
|
||||
persistBoard,
|
||||
@@ -168,135 +222,133 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
[options, item.id, updateItemOptions, persistBoard, board],
|
||||
);
|
||||
|
||||
if (!session) return <>{children}</>;
|
||||
if (!isRightClickEnabled) return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
|
||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||
<Menu.Dropdown>
|
||||
{toggleOptions.length > 0 && (
|
||||
<>
|
||||
<Menu.Label>{tMenu("options")}</Menu.Label>
|
||||
{toggleOptions.map(([key]) => (
|
||||
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Group wrap="nowrap">
|
||||
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
||||
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{visibleWidgetActions.length > 0 && (
|
||||
<>
|
||||
{toggleOptions.length > 0 && <Menu.Divider />}
|
||||
<Menu.Label>{tMenu("actions")}</Menu.Label>
|
||||
{visibleWidgetActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={action.key}
|
||||
closeMenuOnClick
|
||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
color={action.color}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => duplicateItem({ itemId: item.id })}
|
||||
>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{toggleOptions.length > 0 && (
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||
<Menu.Label>{tMenu("options")}</Menu.Label>
|
||||
{toggleOptions.map(([key]) => (
|
||||
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Group wrap="nowrap">
|
||||
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
||||
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{visibleWidgetActions.length > 0 && (
|
||||
<>
|
||||
{toggleOptions.length > 0 && <Menu.Divider />}
|
||||
<Menu.Label>{tMenu("actions")}</Menu.Label>
|
||||
{visibleWidgetActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={action.key}
|
||||
closeMenuOnClick
|
||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
color={action.color}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
closeMenuOnClick
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onFocus={preloadItemMoveModal}
|
||||
onPointerEnter={preloadItemMoveModal}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
{tMenu("refresh")}
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
queryKey={widgetQueryKey}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
</Group>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={openEditModal}
|
||||
disabled={isPending}
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => duplicateItem({ itemId: item.id })}
|
||||
>
|
||||
{tMenu("settings")}
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => removeItem({ itemId: item.id }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
{tMenu("refresh")}
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
queryKeys={widgetQueryKeys}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
disabled={hasSupportedIntegrations && isPending}
|
||||
>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => removeItem({ itemId: item.id }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface WidgetQueryStatusProps {
|
||||
queryClient: QueryClient;
|
||||
queryKey: QueryKey;
|
||||
queryKeys: QueryKey[];
|
||||
isFetching: boolean;
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = queryClient.getQueryCache().findAll({ queryKey });
|
||||
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))];
|
||||
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
||||
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
||||
const ageLabel = useTimeAgo(new Date(latest));
|
||||
@@ -334,7 +386,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="red.6">
|
||||
{t("item.menu.status.error")} · {ageLabel}
|
||||
{t("item.menu.status.error")}
|
||||
{latest > 0 && ` · ${ageLabel}`}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
@@ -345,7 +398,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.success")} · {ageLabel}
|
||||
{t("item.menu.status.success")}
|
||||
{latest > 0 && ` · ${ageLabel}`}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export type Translator = {
|
||||
(key: string): string;
|
||||
};
|
||||
|
||||
export interface HoverMetadataItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const maxMetadataItems = 3;
|
||||
|
||||
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
|
||||
app: true,
|
||||
anchorNote: true,
|
||||
dynamicSelect: true,
|
||||
sortableItemList: true,
|
||||
multiReleasesRepositories: true,
|
||||
umamiEventName: true,
|
||||
umamiEventNames: true,
|
||||
umamiWebsite: true,
|
||||
};
|
||||
|
||||
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
|
||||
|
||||
type OptionDefinitionWithRules = WidgetOptionDefinition & {
|
||||
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
|
||||
options?: readonly { value: unknown; label: unknown }[];
|
||||
};
|
||||
|
||||
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
|
||||
|
||||
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
|
||||
const match = definition.options?.find((option) => option.value === value);
|
||||
const label = match?.label;
|
||||
const fallbackLabel = () => String(value);
|
||||
const labelResolvers: Record<string, () => string> = {
|
||||
function: () => (label as (translator: Translator) => string)(t),
|
||||
string: () => label as string,
|
||||
missing: fallbackLabel,
|
||||
};
|
||||
const labelType = typeof label;
|
||||
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
|
||||
return resolveLabel();
|
||||
};
|
||||
|
||||
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
|
||||
switch: (value, _, t) => {
|
||||
const switchLabels: Record<string, string> = {
|
||||
true: t("common.action.show"),
|
||||
false: t("common.action.hide"),
|
||||
};
|
||||
return switchLabels[String(Boolean(value))] ?? null;
|
||||
},
|
||||
text: (value) => {
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
},
|
||||
number: (value) => String(value),
|
||||
slider: (value) => String(value),
|
||||
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
|
||||
multiSelect: (value) => {
|
||||
const items = value as unknown[];
|
||||
return items.length > 0 ? String(items.length) : null;
|
||||
},
|
||||
multiText: (value) => {
|
||||
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
|
||||
return items.length > 0 ? items.join(", ") : null;
|
||||
},
|
||||
location: (value) => {
|
||||
const location = value as { name?: string };
|
||||
const name = location.name?.trim() ?? "";
|
||||
return name.length > 0 ? name : null;
|
||||
},
|
||||
};
|
||||
|
||||
const formatOptionValue = (
|
||||
type: WidgetOptionType,
|
||||
value: unknown,
|
||||
definition: OptionDefinitionWithRules,
|
||||
t: Translator,
|
||||
) => {
|
||||
const formatter = optionTypeFormatters[type];
|
||||
return formatter?.(value, definition, t) ?? null;
|
||||
};
|
||||
|
||||
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
|
||||
const labelKey = `widget.${kind}.option.${key}.label`;
|
||||
const translated = t(labelKey);
|
||||
return translated === labelKey ? key : translated;
|
||||
};
|
||||
|
||||
export const collectWidgetHoverMetadata = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
integrationKinds: IntegrationKind[],
|
||||
t: Translator,
|
||||
): HoverMetadataItem[] => {
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
|
||||
return objectEntries(optionDefinitions)
|
||||
.filter(([key, optionDefinition]) => {
|
||||
const skip = skippedOptionTypes[optionDefinition.type];
|
||||
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
|
||||
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
|
||||
return !skip && !hidden && !unchanged;
|
||||
})
|
||||
.map(([key, optionDefinition]) => {
|
||||
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
|
||||
return {
|
||||
label: getOptionLabel(item.kind, key, t),
|
||||
value: formattedValue ?? "",
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value.length > 0)
|
||||
.slice(0, maxMetadataItems);
|
||||
};
|
||||
|
||||
interface DisplayNameSource {
|
||||
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
|
||||
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
const displayNameSources: DisplayNameSource[] = [
|
||||
{
|
||||
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
|
||||
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
|
||||
},
|
||||
{
|
||||
matches: (item, options) =>
|
||||
item.kind === "clock" &&
|
||||
Boolean(options.customTitleToggle) &&
|
||||
String(options.customTitle ?? "").trim().length > 0,
|
||||
resolve: (_, options) => String(options.customTitle).trim(),
|
||||
},
|
||||
];
|
||||
|
||||
export const resolveWidgetDisplayName = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
t: Translator,
|
||||
) => {
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
|
||||
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Avatar, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { loadWidgetDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "./widget-hover-overlay.module.css";
|
||||
@@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps {
|
||||
|
||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoadedWidgetHoverOverlay item={item} integrations={integrations} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const t = useI18n();
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const definition = use(loadWidgetDefinition(item.kind));
|
||||
const WidgetIcon = definition.icon;
|
||||
|
||||
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
@@ -28,10 +40,6 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
||||
item.integrationIds.includes(integration.id),
|
||||
);
|
||||
|
||||
if (!isEditMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.panel}>
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
describe("removePersistedWidgetQueries", () => {
|
||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
||||
describe("removeWidgetDataQueries", () => {
|
||||
test("removes only the failed widget prefix and allows fresh data to be fetched", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||
const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }];
|
||||
const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }];
|
||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(sameWidgetQueryKey, "related");
|
||||
queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget");
|
||||
queryClient.setQueryData(appQueryKey, "app");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget");
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBe("app");
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
await expect(
|
||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
||||
).resolves.toBe("fresh");
|
||||
});
|
||||
|
||||
test("removes persisted queries outside the widget router", () => {
|
||||
test("uses an explicit supporting query prefix without purging other supporting data", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||
@@ -31,14 +40,14 @@ describe("removePersistedWidgetQueries", () => {
|
||||
queryClient.setQueryData(appQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]);
|
||||
|
||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBe("stale");
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
});
|
||||
|
||||
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
||||
test("uses the definition query prefix when the widget kind and query path differ", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
@@ -46,7 +55,7 @@ describe("removePersistedWidgetQueries", () => {
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||
|
||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
|
||||
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
|
||||
};
|
||||
@@ -105,12 +105,14 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
|
||||
const settings = useSettings();
|
||||
const openAllInNewTabs = useCallback(async () => {
|
||||
const appIds = filterByItemKind(
|
||||
board.items.filter(
|
||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||
),
|
||||
settings,
|
||||
"app",
|
||||
const appIds = (
|
||||
await filterByItemKind(
|
||||
board.items.filter(
|
||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||
),
|
||||
settings,
|
||||
"app",
|
||||
)
|
||||
).map((item) => {
|
||||
return item.options.appId;
|
||||
});
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets/definition";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const filterByItemKind = <TKind extends WidgetKind>(
|
||||
export const filterByItemKind = async <TKind extends WidgetKind>(
|
||||
items: Item[],
|
||||
settings: SettingsContextProps,
|
||||
kind: TKind,
|
||||
) => {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
|
||||
return items
|
||||
.filter((item) => item.kind === kind)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefaultValues(
|
||||
kind,
|
||||
options: reduceWidgetOptionsWithDefinition(
|
||||
definition,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<TKind>["options"],
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
@@ -15,7 +17,11 @@ import { useSectionItems } from "./use-section-items";
|
||||
|
||||
export const SectionContent = () => {
|
||||
const { innerSections, items, refs } = useSectionContext();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery();
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session) && isEditMode,
|
||||
});
|
||||
|
||||
/**
|
||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
featuredIntegrations,
|
||||
@@ -6,30 +5,8 @@ import {
|
||||
hiddenFromOnboarding,
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
getWidgetKindsForIntegration,
|
||||
} from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
|
||||
const buildWidgetsByIntegration = () => {
|
||||
const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
|
||||
IntegrationKind,
|
||||
WidgetKind[]
|
||||
>;
|
||||
|
||||
for (const [widgetKind, widget] of objectEntries(widgetImports)) {
|
||||
const supported =
|
||||
"supportedIntegrations" in widget.definition
|
||||
? (widget.definition.supportedIntegrations as IntegrationKind[])
|
||||
: [];
|
||||
for (const kind of supported) {
|
||||
if (kind in map) {
|
||||
map[kind].push(widgetKind);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const widgetsByIntegration = buildWidgetsByIntegration();
|
||||
|
||||
export const categoryTranslationKeys: Record<string, string> = {
|
||||
dnsHole: "integration.category.dnsHole",
|
||||
@@ -70,11 +47,11 @@ export interface IntegrationGridItem {
|
||||
|
||||
export const buildSortedIntegrations = (
|
||||
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
||||
): IntegrationGridItem[] =>
|
||||
integrationKinds
|
||||
): IntegrationGridItem[] => {
|
||||
return integrationKinds
|
||||
.filter((kind) => {
|
||||
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
||||
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
|
||||
if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false;
|
||||
if (!options.enableMockIntegration && kind === "mock") return false;
|
||||
return true;
|
||||
})
|
||||
@@ -82,9 +59,9 @@ export const buildSortedIntegrations = (
|
||||
kind,
|
||||
name: getIntegrationName(kind),
|
||||
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
|
||||
widgets: widgetsByIntegration[kind],
|
||||
widgets: getWidgetKindsForIntegration(kind),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
.toSorted((left, right) => {
|
||||
const leftIdx = featuredIntegrations.indexOf(left.kind);
|
||||
const rightIdx = featuredIntegrations.indexOf(right.kind);
|
||||
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
|
||||
@@ -92,6 +69,7 @@ export const buildSortedIntegrations = (
|
||||
if (rightIdx !== -1) return 1;
|
||||
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
|
||||
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { Spotlight } from "@homarr/spotlight";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
@@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
import { UserButton } from "./header/user";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
import { LazySpotlight } from "./header/lazy-spotlight";
|
||||
|
||||
interface Props {
|
||||
logo?: ReactNode;
|
||||
@@ -37,7 +37,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<Spotlight />
|
||||
<LazySpotlight />
|
||||
</AppShellHeader>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,9 @@ type HeaderButtonProps = (
|
||||
}
|
||||
) & {
|
||||
children: ReactNode;
|
||||
onFocus?: () => void;
|
||||
onPointerDown?: () => void;
|
||||
onPointerEnter?: () => void;
|
||||
} & Partial<ActionIconProps>;
|
||||
|
||||
const headerButtonActionIconProps: ActionIconProps = {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Center, Loader, Paper } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open";
|
||||
|
||||
const loadSpotlight = () => import("@homarr/spotlight/component");
|
||||
const SpotlightLoading = () => (
|
||||
<Center
|
||||
data-homarr-dev-benchmark-spotlight-feedback
|
||||
pos="fixed"
|
||||
inset={0}
|
||||
role="status"
|
||||
aria-label="Loading search"
|
||||
style={{ zIndex: 1000, background: "rgb(0 0 0 / 20%)", backdropFilter: "blur(2px)" }}
|
||||
>
|
||||
<Paper withBorder radius="md" p="xl" shadow="xl">
|
||||
<Loader />
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), {
|
||||
ssr: false,
|
||||
loading: SpotlightLoading,
|
||||
});
|
||||
|
||||
export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined);
|
||||
|
||||
export const LazySpotlight = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isPreloaded, setIsPreloaded] = useState(false);
|
||||
|
||||
useHotkeys(
|
||||
isMounted
|
||||
? []
|
||||
: [
|
||||
[
|
||||
hotkeys.openSpotlight,
|
||||
() => {
|
||||
setIsMounted(true);
|
||||
openSpotlight();
|
||||
},
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const mount = () => setIsMounted(true);
|
||||
window.addEventListener(spotlightOpenEvent, mount);
|
||||
if (hasPendingSpotlightOpen()) mount();
|
||||
|
||||
const preload = () => {
|
||||
void loadSpotlight().then(
|
||||
() => {
|
||||
if (isActive) setIsPreloaded(true);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
};
|
||||
let idleId: number | undefined;
|
||||
const delayId = window.setTimeout(() => {
|
||||
idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 });
|
||||
if (idleId === undefined) preload();
|
||||
}, 1_000);
|
||||
return () => {
|
||||
isActive = false;
|
||||
window.removeEventListener(spotlightOpenEvent, mount);
|
||||
window.clearTimeout(delayId);
|
||||
if (idleId !== undefined) window.cancelIdleCallback?.(idleId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMounted || isPreloaded ? (
|
||||
<>
|
||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||
{isMounted && (
|
||||
<>
|
||||
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
|
||||
<Spotlight />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
@@ -3,23 +3,28 @@
|
||||
import { TextInput, UnstyledButton } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { openSpotlight } from "@homarr/spotlight";
|
||||
import { openSpotlight } from "@homarr/spotlight/open";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "./button";
|
||||
import classes from "./search.module.css";
|
||||
import { preloadSpotlight } from "./lazy-spotlight";
|
||||
|
||||
export const DesktopSearchInput = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
data-homarr-dev-benchmark-interaction="search"
|
||||
component={UnstyledButton}
|
||||
className={classes.desktopSearch}
|
||||
w={400}
|
||||
size="sm"
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
onClick={openSpotlight}
|
||||
onFocus={preloadSpotlight}
|
||||
onPointerDown={preloadSpotlight}
|
||||
onPointerEnter={preloadSpotlight}
|
||||
radius="xl"
|
||||
>
|
||||
{`${t("search.placeholder")}...`}
|
||||
@@ -29,7 +34,14 @@ export const DesktopSearchInput = () => {
|
||||
|
||||
export const MobileSearchButton = () => {
|
||||
return (
|
||||
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
|
||||
<HeaderButton
|
||||
data-homarr-dev-benchmark-interaction="search"
|
||||
onClick={openSpotlight}
|
||||
onFocus={preloadSpotlight}
|
||||
onPointerDown={preloadSpotlight}
|
||||
onPointerEnter={preloadSpotlight}
|
||||
className={classes.mobileSearch}
|
||||
>
|
||||
<IconSearch size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TourTarget, TourTargetsProvider } from "./tour-target";
|
||||
|
||||
vi.mock("@gfazioli/mantine-onboarding-tour", () => ({
|
||||
OnboardingTour: {
|
||||
Target: ({ children }: { children: ReactNode }) => children,
|
||||
},
|
||||
}));
|
||||
|
||||
const mountedRoots: Array<ReturnType<typeof createRoot>> = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of mountedRoots.splice(0)) root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
const renderTargetAsync = async (enabled: boolean) => {
|
||||
const host = document.createElement("div");
|
||||
const root = createRoot(host);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(TourTargetsProvider, {
|
||||
enabled,
|
||||
children: createElement(TourTarget, { id: "example", children: createElement("button", null, "Target") }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
return host;
|
||||
};
|
||||
|
||||
describe("TourTarget", () => {
|
||||
it("keeps a stable route-transition marker while a tour is active", async () => {
|
||||
const host = await renderTargetAsync(true);
|
||||
expect(host.querySelector('[data-tour-target="example"] button')?.textContent).toBe("Target");
|
||||
});
|
||||
|
||||
it("adds no wrapper for users without an active tour", async () => {
|
||||
const host = await renderTargetAsync(false);
|
||||
expect(host.querySelector("[data-tour-target]")).toBeNull();
|
||||
expect(host.querySelector("button")?.parentElement).toBe(host);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { createContext, Suspense, use, useContext } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
|
||||
<OnboardingTour.Target id={id}>
|
||||
<Box data-tour-target={id}>{children}</Box>
|
||||
</OnboardingTour.Target>
|
||||
const TourTargetsEnabledContext = createContext(false);
|
||||
let tourLibraryPromise: Promise<typeof import("@gfazioli/mantine-onboarding-tour")> | undefined;
|
||||
|
||||
const loadTourLibrary = () => {
|
||||
if (tourLibraryPromise) return tourLibraryPromise;
|
||||
|
||||
const currentPromise = import("@gfazioli/mantine-onboarding-tour");
|
||||
tourLibraryPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (tourLibraryPromise === currentPromise) tourLibraryPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
export const TourTargetsProvider = ({ enabled = true, children }: PropsWithChildren<{ enabled?: boolean }>) => (
|
||||
<TourTargetsEnabledContext value={enabled}>{children}</TourTargetsEnabledContext>
|
||||
);
|
||||
|
||||
const LoadedTourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||
const { OnboardingTour } = use(loadTourLibrary());
|
||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||
};
|
||||
|
||||
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||
const isEnabled = useContext(TourTargetsEnabledContext);
|
||||
if (!isEnabled) return children;
|
||||
|
||||
const target = <Box data-tour-target={id}>{children}</Box>;
|
||||
|
||||
return (
|
||||
<Suspense fallback={target}>
|
||||
<LoadedTourTarget id={id}>{target}</LoadedTourTarget>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Suspense, use } from "react";
|
||||
import { Indicator, Menu, Text } from "@mantine/core";
|
||||
import { IconBellRinging } from "@tabler/icons-react";
|
||||
|
||||
@@ -9,67 +8,28 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UpdateIndicatorProps extends PropsWithChildren {
|
||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
||||
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
|
||||
if (disabled || availableUpdatesPromise === undefined) {
|
||||
export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => {
|
||||
if (disabled || availableUpdates === undefined) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={children}>
|
||||
<InnerUpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={disabled}>
|
||||
{children}
|
||||
</InnerUpdateIndicator>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerUpdateIndicatorProps extends PropsWithChildren {
|
||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const InnerUpdateIndicator = ({ children, disabled, availableUpdatesPromise }: InnerUpdateIndicatorProps) => {
|
||||
const availableUpdates = use(availableUpdatesPromise);
|
||||
|
||||
return (
|
||||
<Indicator
|
||||
disabled={!availableUpdates || availableUpdates.length === 0 || disabled}
|
||||
size={15}
|
||||
processing
|
||||
withBorder
|
||||
>
|
||||
<Indicator disabled={availableUpdates.length === 0} size={15} processing withBorder>
|
||||
{children}
|
||||
</Indicator>
|
||||
);
|
||||
};
|
||||
|
||||
interface AvailableUpdatesMenuItemProps {
|
||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
||||
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
||||
}
|
||||
|
||||
export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => {
|
||||
if (availableUpdatesPromise === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<InnerAvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerAvailableUpdatesMenuItemProps {
|
||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||
}
|
||||
|
||||
const InnerAvailableUpdatesMenuItem = ({ availableUpdatesPromise }: InnerAvailableUpdatesMenuItemProps) => {
|
||||
export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const availableUpdates = use(availableUpdatesPromise);
|
||||
if (availableUpdates === undefined || availableUpdates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Suspense, use, useEffect, useState } from "react";
|
||||
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
|
||||
import { UpdateIndicator } from "./update";
|
||||
|
||||
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
|
||||
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
|
||||
|
||||
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
|
||||
const loadUserAvatarMenu = () => {
|
||||
if (userAvatarMenuPromise) return userAvatarMenuPromise;
|
||||
|
||||
const promise = import("~/components/user-avatar-menu");
|
||||
userAvatarMenuPromise = promise;
|
||||
void promise.catch(() => {
|
||||
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
|
||||
|
||||
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
|
||||
const { UserAvatarMenu } = use(loadUserAvatarMenu());
|
||||
return <UserAvatarMenu {...props} />;
|
||||
};
|
||||
|
||||
interface UserButtonClientProps {
|
||||
avatar: ReactNode;
|
||||
isAdmin: boolean;
|
||||
isDockerEnabled: boolean;
|
||||
}
|
||||
|
||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
||||
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||
const [isMenuMounted, setIsMenuMounted] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const session = useSession();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
const isCurrentSessionAdmin =
|
||||
isAdmin && session.status === "authenticated" && session.data.user.permissions.includes("admin");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCurrentSessionAdmin) {
|
||||
setCanCheckForUpdates(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => setCanCheckForUpdates(true), 2_000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [isCurrentSessionAdmin]);
|
||||
|
||||
const { data: availableUpdates } = clientApi.updateChecker.getAvailableUpdates.useQuery(undefined, {
|
||||
enabled: canCheckForUpdates,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 60 * 60 * 1_000,
|
||||
});
|
||||
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||
const openMenu = () => {
|
||||
setIsMenuMounted(true);
|
||||
setIsMenuOpen(true);
|
||||
};
|
||||
const renderButton = (onClick: () => void, isLoading = false) => (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
onFocus={preloadUserAvatarMenu}
|
||||
onMouseEnter={preloadUserAvatarMenu}
|
||||
onPointerDown={preloadUserAvatarMenu}
|
||||
aria-busy={isLoading || undefined}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
<Box pos="relative" style={{ display: "inline-flex" }}>
|
||||
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
|
||||
{isLoading && (
|
||||
<Loader
|
||||
aria-label="Loading user menu"
|
||||
size={18}
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
style={{ transform: "translate(-50%, -50%)" }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</UpdateIndicator>
|
||||
</UnstyledButton>
|
||||
);
|
||||
const button = renderButton(openMenu);
|
||||
const loadingButton = renderButton(() => undefined, true);
|
||||
|
||||
if (!isMenuMounted) return button;
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary }) =>
|
||||
renderButton(() => {
|
||||
resetErrorBoundary();
|
||||
openMenu();
|
||||
})
|
||||
}
|
||||
>
|
||||
<Suspense fallback={loadingButton}>
|
||||
<LazyUserAvatarMenu
|
||||
availableUpdates={visibleUpdates}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
opened={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
{button}
|
||||
</LazyUserAvatarMenu>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,14 @@
|
||||
import { Suspense } from "react";
|
||||
import { UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
import { UpdateIndicator } from "./update";
|
||||
import { UserButtonClient } from "./user-client";
|
||||
|
||||
export const UserButton = async () => {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||
return (
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||
<CurrentUserAvatar size="md" />
|
||||
</UpdateIndicator>
|
||||
</Suspense>
|
||||
</UnstyledButton>
|
||||
</UserAvatarMenu>
|
||||
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { NavLink } from "@mantine/core";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
|
||||
export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
||||
|
||||
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
||||
if (!id) return <>{children}</>;
|
||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||
};
|
||||
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
|
||||
id ? <TourTarget id={id}>{children}</TourTarget> : children;
|
||||
|
||||
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
@@ -43,7 +42,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
active={isClient && pathMatches(pathname, props.href)}
|
||||
/>
|
||||
);
|
||||
return <TourTarget id={tourId}>{link}</TourTarget>;
|
||||
return withOptionalTourTarget(tourId, link);
|
||||
};
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
@@ -56,7 +55,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
||||
return withOptionalTourTarget(props["data-onboarding-tour-id"], nav);
|
||||
};
|
||||
|
||||
interface CommonNavigationLinkProps {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export const BoardTourGate = async ({ enabled, children }: PropsWithChildren<{ enabled: boolean }>) => {
|
||||
if (!enabled) return children;
|
||||
|
||||
// Native server-side import keeps the completed-user client graph lean while
|
||||
// giving first-time users one stable board tree instead of swapping a
|
||||
// Suspense fallback after GridStack has mounted.
|
||||
const { BoardTourProvider } = await import("./board-tour");
|
||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -10,6 +10,7 @@ import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
|
||||
import { TourShell } from "./tour-shell";
|
||||
import { TourStepContent } from "./tour-step-content";
|
||||
|
||||
@@ -17,21 +18,11 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useScopedI18n("onboardingTour.board");
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||
onSuccess() {
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
|
||||
const [started, setStarted] = useState(true);
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
|
||||
|
||||
const handleEnd = () => {
|
||||
utils.user.getTourStatus.setData(undefined, {
|
||||
completedManageTour: tourStatus?.completedManageTour ?? false,
|
||||
completedBoardTour: true,
|
||||
});
|
||||
setStarted(false);
|
||||
completeTour({ tour: "board" });
|
||||
};
|
||||
|
||||
@@ -101,8 +92,10 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [hasChangeAccess, t]);
|
||||
|
||||
return (
|
||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||
{children}
|
||||
</TourShell>
|
||||
<TourTargetsProvider enabled={started}>
|
||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||
{children}
|
||||
</TourShell>
|
||||
</TourTargetsProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export const ManageTourGate = async ({
|
||||
enabled,
|
||||
isAdmin,
|
||||
children,
|
||||
}: PropsWithChildren<{ enabled: boolean; isAdmin: boolean }>) => {
|
||||
if (!enabled) return children;
|
||||
|
||||
const { ManageTourProvider } = await import("./manage-tour");
|
||||
return <ManageTourProvider isAdmin={isAdmin}>{children}</ManageTourProvider>;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
@@ -17,6 +17,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
|
||||
import { TourShell } from "./tour-shell";
|
||||
import { TourStepContent } from "./tour-step-content";
|
||||
|
||||
@@ -46,13 +47,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
|
||||
|
||||
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
|
||||
const t = useScopedI18n("onboardingTour.manage");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||
onSuccess() {
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -62,14 +57,13 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
||||
|
||||
const [tourActive, setTourActive] = useState(false);
|
||||
const [tourDismissed, setTourDismissed] = useState(false);
|
||||
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
|
||||
|
||||
useEffect(() => {
|
||||
if (tourDismissed) return;
|
||||
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
|
||||
if (isManageHome && !isMobile) {
|
||||
setTourActive(true);
|
||||
}
|
||||
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
|
||||
}, [isManageHome, isMobile, tourDismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManageSection) {
|
||||
@@ -77,25 +71,12 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
||||
}
|
||||
}, [isManageSection]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasCompleted = wasManageTourCompletedRef.current;
|
||||
const isCompleted = tourStatus?.completedManageTour;
|
||||
if (wasCompleted === true && isCompleted === false) {
|
||||
setTourDismissed(false);
|
||||
}
|
||||
wasManageTourCompletedRef.current = isCompleted;
|
||||
}, [tourStatus?.completedManageTour]);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
setTourDismissed(true);
|
||||
setTourActive(false);
|
||||
utils.user.getTourStatus.setData(undefined, {
|
||||
completedManageTour: true,
|
||||
completedBoardTour: tourStatus?.completedBoardTour ?? false,
|
||||
});
|
||||
completeTour({ tour: "manage" });
|
||||
router.push("/manage");
|
||||
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
|
||||
}, [completeTour, router]);
|
||||
|
||||
const steps = useMemo(() => {
|
||||
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
|
||||
@@ -207,14 +188,16 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
||||
}, [isAdmin, t]);
|
||||
|
||||
return (
|
||||
<TourShell
|
||||
steps={steps}
|
||||
started={tourActive}
|
||||
onEnd={handleEnd}
|
||||
stepRoutes={stepRoutes}
|
||||
position={{ base: "bottom", sm: "right" }}
|
||||
>
|
||||
{children}
|
||||
</TourShell>
|
||||
<TourTargetsProvider enabled={tourActive}>
|
||||
<TourShell
|
||||
steps={steps}
|
||||
started={tourActive}
|
||||
onEnd={handleEnd}
|
||||
stepRoutes={stepRoutes}
|
||||
position={{ base: "bottom", sm: "right" }}
|
||||
>
|
||||
{children}
|
||||
</TourShell>
|
||||
</TourTargetsProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconCheck,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconBrandDocker, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
@@ -30,42 +19,38 @@ import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||
|
||||
interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
isDockerEnabled?: boolean;
|
||||
opened: boolean;
|
||||
onOpenChange: (opened: boolean) => void;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({
|
||||
children,
|
||||
availableUpdates,
|
||||
isDockerEnabled,
|
||||
opened,
|
||||
onOpenChange,
|
||||
}: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
const { openModal } = useModalAction(LogoutModal);
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
|
||||
const handleSignout = useCallback(async () => {
|
||||
const redirectUrl = logoutUrl ?? "/auth/login";
|
||||
await signOut({
|
||||
redirect: false,
|
||||
});
|
||||
openModal({
|
||||
onTimeout: () => {
|
||||
if (logoutUrl) {
|
||||
window.location.assign(logoutUrl);
|
||||
return;
|
||||
}
|
||||
router.push("/auth/login");
|
||||
},
|
||||
});
|
||||
}, [logoutUrl, openModal, router]);
|
||||
window.location.assign(redirectUrl);
|
||||
}, [logoutUrl]);
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
@@ -113,35 +98,3 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const LogoutModal = createModal<{ onTimeout: () => void }>(({ actions, innerProps }) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const { start } = useTimeout(() => {
|
||||
actions.closeModal();
|
||||
innerProps.onTimeout();
|
||||
}, 1500);
|
||||
|
||||
useEffect(() => {
|
||||
start();
|
||||
}, [start]);
|
||||
|
||||
return (
|
||||
<Center h={200 - 2 * 16}>
|
||||
<Stack align="center" c="green">
|
||||
<IconCheck size={50} />
|
||||
<Text ta="center" fw="bold">
|
||||
{t("loggedOut")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}).withOptions({
|
||||
centered: true,
|
||||
withCloseButton: false,
|
||||
transitionProps: {
|
||||
transition: "pop",
|
||||
},
|
||||
size: 200,
|
||||
closeOnClickOutside: false,
|
||||
closeOnEscape: false,
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// Development runs background services as separate processes. The aliases in
|
||||
// next.config.ts keep production-only instrumentation imports out of the graph.
|
||||
export const instrumentationNoop = true;
|
||||
export const startupPromise = Promise.resolve();
|
||||
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const registerAsync = async (nodeEnv: string, nextRuntime: string) => {
|
||||
const loggerLoaded = vi.fn();
|
||||
const tasksStarted = vi.fn();
|
||||
const websocketStarted = vi.fn();
|
||||
vi.stubEnv("NODE_ENV", nodeEnv);
|
||||
vi.stubEnv("NEXT_RUNTIME", nextRuntime);
|
||||
vi.doMock("@homarr/core/infrastructure/logs", () => {
|
||||
loggerLoaded();
|
||||
return { createLogger: () => ({ error: vi.fn() }) };
|
||||
});
|
||||
vi.doMock("@homarr/tasks", () => {
|
||||
tasksStarted();
|
||||
return { startupPromise: Promise.resolve() };
|
||||
});
|
||||
vi.doMock("@homarr/websocket", () => {
|
||||
websocketStarted();
|
||||
return { startupPromise: Promise.resolve() };
|
||||
});
|
||||
|
||||
const { register } = await import("./instrumentation");
|
||||
await register();
|
||||
if (nodeEnv === "production" && nextRuntime === "nodejs") {
|
||||
await vi.waitFor(() => {
|
||||
expect(tasksStarted).toHaveBeenCalledOnce();
|
||||
expect(websocketStarted).toHaveBeenCalledOnce();
|
||||
});
|
||||
}
|
||||
return { loggerLoaded, tasksStarted, websocketStarted };
|
||||
};
|
||||
|
||||
describe("Next instrumentation", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock("@homarr/core/infrastructure/logs");
|
||||
vi.doUnmock("@homarr/tasks");
|
||||
vi.doUnmock("@homarr/websocket");
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("embeds background services in the production Node runtime", async () => {
|
||||
const { loggerLoaded, tasksStarted, websocketStarted } = await registerAsync("production", "nodejs");
|
||||
|
||||
expect(loggerLoaded).toHaveBeenCalledOnce();
|
||||
expect(tasksStarted).toHaveBeenCalledOnce();
|
||||
expect(websocketStarted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("does not embed background services in the development server", async () => {
|
||||
const { loggerLoaded, tasksStarted, websocketStarted } = await registerAsync("development", "nodejs");
|
||||
|
||||
expect(loggerLoaded).not.toHaveBeenCalled();
|
||||
expect(tasksStarted).not.toHaveBeenCalled();
|
||||
expect(websocketStarted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not embed Node services in the edge runtime", async () => {
|
||||
const { loggerLoaded, tasksStarted, websocketStarted } = await registerAsync("production", "edge");
|
||||
|
||||
expect(loggerLoaded).not.toHaveBeenCalled();
|
||||
expect(tasksStarted).not.toHaveBeenCalled();
|
||||
expect(websocketStarted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not block Next readiness on slow cron run-on-start hooks", async () => {
|
||||
const neverFinishes = new Promise<void>(() => undefined);
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||
vi.doMock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => ({ error: vi.fn() }) }));
|
||||
vi.doMock("@homarr/tasks", () => ({ startupPromise: neverFinishes }));
|
||||
vi.doMock("@homarr/websocket", () => ({ startupPromise: Promise.resolve() }));
|
||||
|
||||
const { register } = await import("./instrumentation");
|
||||
|
||||
await expect(register()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("terminates a ready Node process when cron startup later fails", async () => {
|
||||
const websocketStarted = vi.fn();
|
||||
const errorLogged = vi.fn();
|
||||
let rejectTasks!: (error: Error) => void;
|
||||
const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||
vi.doMock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({ error: errorLogged }),
|
||||
}));
|
||||
vi.doMock("@homarr/tasks", () => ({
|
||||
startupPromise: new Promise<void>((_resolve, reject) => {
|
||||
rejectTasks = reject;
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@homarr/websocket", () => {
|
||||
websocketStarted();
|
||||
return { startupPromise: Promise.resolve() };
|
||||
});
|
||||
|
||||
const { register } = await import("./instrumentation");
|
||||
|
||||
await expect(register()).resolves.toBeUndefined();
|
||||
rejectTasks(new Error("task startup failed"));
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
||||
expect(websocketStarted).toHaveBeenCalledOnce();
|
||||
expect(errorLogged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("fails readiness when the embedded WebSocket listener cannot start", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||
vi.doMock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => ({ error: vi.fn() }) }));
|
||||
vi.doMock("@homarr/tasks", () => ({ startupPromise: Promise.resolve() }));
|
||||
vi.doMock("@homarr/websocket", () => ({ startupPromise: Promise.reject(new Error("port unavailable")) }));
|
||||
|
||||
const { register } = await import("./instrumentation");
|
||||
|
||||
await expect(register()).rejects.toThrow("Failed to start embedded websocket service");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,38 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("@homarr/tasks");
|
||||
await import("@homarr/websocket");
|
||||
}
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
|
||||
const { createLogger } = await import("@homarr/core/infrastructure/logs");
|
||||
const logger = createLogger({ module: "instrumentation" });
|
||||
|
||||
const startTasksAsync = async () => {
|
||||
try {
|
||||
const tasks = await import("@homarr/tasks");
|
||||
// Cron run-on-start hooks can perform slow external work. Keep dashboard
|
||||
// readiness independent, but never leave the process permanently degraded.
|
||||
void tasks.startupPromise.catch((cause: unknown) => {
|
||||
logger.error(new Error("Failed to start embedded tasks service", { cause }));
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (cause) {
|
||||
const error = new Error("Failed to load embedded tasks service", { cause });
|
||||
logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const startWebsocketAsync = async () => {
|
||||
try {
|
||||
const websocket = await import("@homarr/websocket");
|
||||
// Listening is a bounded local readiness condition and prevents Next from
|
||||
// becoming healthy with subscriptions unavailable.
|
||||
await websocket.startupPromise;
|
||||
} catch (cause) {
|
||||
const error = new Error("Failed to start embedded websocket service", { cause });
|
||||
logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([startTasksAsync(), startWebsocketAsync()]);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { localeCookieKey } from "@homarr/definitions";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getOnboardingStep: vi.fn(),
|
||||
getDefaultLocale: vi.fn(),
|
||||
createI18nMiddleware: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/db/proxy-reader", () => ({
|
||||
getDefaultLocaleForProxyAsync: mocks.getDefaultLocale,
|
||||
getOnboardingStepForProxyAsync: mocks.getOnboardingStep,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/translation/middleware", () => ({
|
||||
createI18nMiddleware: mocks.createI18nMiddleware,
|
||||
}));
|
||||
|
||||
const loadProxy = async () => {
|
||||
vi.resetModules();
|
||||
return import("./proxy");
|
||||
};
|
||||
|
||||
const createRequest = (pathname: string, locale?: string) => {
|
||||
const request = new NextRequest(`http://localhost${pathname}`);
|
||||
if (locale) request.cookies.set(localeCookieKey, locale);
|
||||
return request;
|
||||
};
|
||||
|
||||
describe("locale and onboarding proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getOnboardingStep.mockResolvedValue("finish");
|
||||
mocks.getDefaultLocale.mockResolvedValue("en");
|
||||
mocks.createI18nMiddleware.mockImplementation((defaultLocale: string) => (request: NextRequest) => {
|
||||
request.headers.set("x-test-default-locale", defaultLocale);
|
||||
return NextResponse.next();
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects unfinished onboarding to /init", async () => {
|
||||
mocks.getOnboardingStep.mockResolvedValue("integrations");
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
const response = await proxy(createRequest("/en/dashboard"));
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe("http://localhost/init");
|
||||
expect(mocks.getOnboardingStep).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows /init while onboarding is unfinished", async () => {
|
||||
mocks.getOnboardingStep.mockResolvedValue("start");
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
const response = await proxy(createRequest("/en/init"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.getOnboardingStep).not.toHaveBeenCalled();
|
||||
expect(mocks.getDefaultLocale).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the configured default locale for a missing or invalid locale cookie", async () => {
|
||||
mocks.getDefaultLocale.mockResolvedValue("de");
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
await proxy(createRequest("/en/dashboard", "not-a-locale"));
|
||||
|
||||
expect(mocks.getDefaultLocale).toHaveBeenCalledOnce();
|
||||
expect(mocks.createI18nMiddleware).toHaveBeenCalledWith("de");
|
||||
});
|
||||
|
||||
it("keeps a valid locale cookie and does not query the configured default", async () => {
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
const response = await proxy(createRequest("/fr/dashboard", "fr"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.getDefaultLocale).not.toHaveBeenCalled();
|
||||
expect(mocks.createI18nMiddleware).toHaveBeenCalledWith("en");
|
||||
});
|
||||
|
||||
it("deduplicates concurrent cold-start database reads", async () => {
|
||||
let finishOnboarding!: (step: string) => void;
|
||||
let finishLocale!: (locale: string) => void;
|
||||
mocks.getOnboardingStep.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finishOnboarding = resolve;
|
||||
}),
|
||||
);
|
||||
mocks.getDefaultLocale.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finishLocale = resolve;
|
||||
}),
|
||||
);
|
||||
const { proxy } = await loadProxy();
|
||||
const responses = Array.from({ length: 50 }, () => proxy(createRequest("/en/dashboard", "not-a-locale")));
|
||||
|
||||
await vi.waitFor(() => expect(mocks.getOnboardingStep).toHaveBeenCalledOnce());
|
||||
finishOnboarding("finish");
|
||||
await vi.waitFor(() => expect(mocks.getDefaultLocale).toHaveBeenCalledOnce());
|
||||
finishLocale("de");
|
||||
|
||||
await expect(Promise.all(responses)).resolves.toHaveLength(50);
|
||||
expect(mocks.getOnboardingStep).toHaveBeenCalledOnce();
|
||||
expect(mocks.getDefaultLocale).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears failed in-flight reads so the next request can recover", async () => {
|
||||
mocks.getOnboardingStep.mockRejectedValueOnce(new Error("temporary database failure")).mockResolvedValue("finish");
|
||||
const { proxy } = await loadProxy();
|
||||
|
||||
await expect(proxy(createRequest("/en/dashboard", "en"))).rejects.toThrow("temporary database failure");
|
||||
await expect(proxy(createRequest("/en/dashboard", "en"))).resolves.toMatchObject({ status: 200 });
|
||||
|
||||
expect(mocks.getOnboardingStep).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,51 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { localeCookieKey } from "@homarr/definitions";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { supportedLanguages } from "@homarr/translation";
|
||||
import { getDefaultLocaleForProxyAsync, getOnboardingStepForProxyAsync } from "@homarr/db/proxy-reader";
|
||||
import { localeCookieKey } from "@homarr/definitions/cookie";
|
||||
import type { SupportedLanguage } from "@homarr/translation/languages";
|
||||
import { supportedLanguages } from "@homarr/translation/languages";
|
||||
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
||||
|
||||
let isOnboardingFinished = false;
|
||||
let onboardingStepPromise: Promise<string> | null = null;
|
||||
let defaultLocalePromise: Promise<string> | null = null;
|
||||
|
||||
const getOnboardingStepDedupedAsync = () => {
|
||||
onboardingStepPromise ??= getOnboardingStepForProxyAsync().then(
|
||||
(step) => {
|
||||
onboardingStepPromise = null;
|
||||
return step;
|
||||
},
|
||||
(error: unknown) => {
|
||||
onboardingStepPromise = null;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return onboardingStepPromise;
|
||||
};
|
||||
|
||||
const getDefaultLocaleDedupedAsync = () => {
|
||||
defaultLocalePromise ??= getDefaultLocaleForProxyAsync().then(
|
||||
(locale) => {
|
||||
defaultLocalePromise = null;
|
||||
return locale;
|
||||
},
|
||||
(error: unknown) => {
|
||||
defaultLocalePromise = null;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return defaultLocalePromise;
|
||||
};
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
// Redirect to onboarding if it's not finished yet
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
if (!isOnboardingFinished && !pathname.endsWith("/init")) {
|
||||
const currentOnboardingStep = await api.onboard.currentStep();
|
||||
if (currentOnboardingStep.current !== "finish") {
|
||||
const currentOnboardingStep = await getOnboardingStepDedupedAsync();
|
||||
if (currentOnboardingStep !== "finish") {
|
||||
return NextResponse.redirect(new URL("/init", request.url));
|
||||
}
|
||||
|
||||
@@ -26,7 +56,10 @@ export async function proxy(request: NextRequest) {
|
||||
const currentLocale = request.cookies.get(localeCookieKey)?.value;
|
||||
let defaultLocale: SupportedLanguage = "en";
|
||||
if (!currentLocale || !supportedLanguages.includes(currentLocale as SupportedLanguage)) {
|
||||
defaultLocale = await api.serverSettings.getCulture().then((culture) => culture.defaultLocale);
|
||||
const configuredLocale = await getDefaultLocaleDedupedAsync();
|
||||
if (supportedLanguages.includes(configuredLocale as SupportedLanguage)) {
|
||||
defaultLocale = configuredLocale as SupportedLanguage;
|
||||
}
|
||||
}
|
||||
|
||||
// We don't want to fallback to accept-language header so we clear it
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
|
||||
@@ -13,6 +12,6 @@ export const getCurrentColorSchemeAsync = cache(async () => {
|
||||
return cookieValue as ColorScheme;
|
||||
}
|
||||
|
||||
const appearanceSettings = await getServerSettingByKeyAsync(db, "appearance");
|
||||
return appearanceSettings.defaultColorScheme;
|
||||
const serverSettings = await getRscServerSettingsAsync();
|
||||
return serverSettings.appearance.defaultColorScheme;
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { jobGroup } from "@homarr/cron-jobs";
|
||||
|
||||
import { onStartAsync } from "./on-start";
|
||||
|
||||
void (async () => {
|
||||
export const startupPromise = (async () => {
|
||||
await onStartAsync();
|
||||
await jobGroup.initializeAsync();
|
||||
await jobGroup.startAllAsync();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { cleanupSessionsAsync } from "./session-cleanup";
|
||||
import { warmUpdateCheckerAsync } from "./warm-update-checker";
|
||||
|
||||
export async function onStartAsync() {
|
||||
await cleanupSessionsAsync();
|
||||
await warmUpdateCheckerAsync();
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
|
||||
|
||||
const logger = createLogger({ module: "warmUpdateChecker" });
|
||||
|
||||
export async function warmUpdateCheckerAsync() {
|
||||
try {
|
||||
const handler = updateCheckerRequestHandler.handler({});
|
||||
await handler.getDataAsync();
|
||||
logger.debug("Update checker warmed");
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to warm update checker", { cause: error }));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,25 @@ const logger = createLogger({ module: "websocketMain" });
|
||||
const wss = new WebSocketServer({
|
||||
port: 3001,
|
||||
});
|
||||
wss.on("error", (error) => {
|
||||
logger.error(new Error("WebSocket server error", { cause: error }));
|
||||
});
|
||||
|
||||
export const startupPromise = new Promise<void>((resolve, reject) => {
|
||||
const onListening = () => {
|
||||
wss.off("error", onError);
|
||||
logger.info("✅ WebSocket Server listening on ws://localhost:3001");
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
wss.off("listening", onListening);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
wss.once("listening", onListening);
|
||||
wss.once("error", onError);
|
||||
});
|
||||
|
||||
const handler = applyWSSHandler({
|
||||
wss,
|
||||
router: appRouter,
|
||||
@@ -57,7 +76,6 @@ wss.on("connection", (websocket, incomingMessage) => {
|
||||
logger.info(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`);
|
||||
});
|
||||
});
|
||||
logger.info("✅ WebSocket Server listening on ws://localhost:3001");
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
logger.info("SIGTERM");
|
||||
|
||||
@@ -45,8 +45,9 @@ describe("Custom JSX custom widgets", () => {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await page.waitForURL(baseUrl, { timeout: 15_000 });
|
||||
await loginNavigation;
|
||||
|
||||
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
|
||||
await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 });
|
||||
|
||||
+26
-18
@@ -8,13 +8,17 @@ describe("Health checks", () => {
|
||||
// Arrange
|
||||
const homarrContainer = await createHomarrContainer().start();
|
||||
|
||||
// Act
|
||||
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
|
||||
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
|
||||
try {
|
||||
// Act
|
||||
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
|
||||
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
|
||||
|
||||
// Assert
|
||||
expect(readyResponse.status).toBe(200);
|
||||
expect(liveResponse.status).toBe(200);
|
||||
// Assert
|
||||
expect(readyResponse.status).toBe(200);
|
||||
expect(liveResponse.status).toBe(200);
|
||||
} finally {
|
||||
await homarrContainer.stop();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("ready and live should return 200 OK with external redis", async () => {
|
||||
@@ -29,18 +33,22 @@ describe("Health checks", () => {
|
||||
},
|
||||
}).start();
|
||||
|
||||
// Act
|
||||
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
|
||||
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
|
||||
try {
|
||||
// Act
|
||||
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
|
||||
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
readyResponse.status,
|
||||
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
|
||||
).toBe(200);
|
||||
expect(
|
||||
liveResponse.status,
|
||||
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
|
||||
).toBe(200);
|
||||
// Assert
|
||||
expect(
|
||||
readyResponse.status,
|
||||
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
|
||||
).toBe(200);
|
||||
expect(
|
||||
liveResponse.status,
|
||||
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
|
||||
).toBe(200);
|
||||
} finally {
|
||||
await Promise.all([homarrContainer.stop(), redisContainer.stop()]);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
+8
-4
@@ -7,10 +7,14 @@ describe("Home", () => {
|
||||
// Arrange
|
||||
const homarrContainer = await createHomarrContainer().start();
|
||||
|
||||
// Act
|
||||
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
|
||||
try {
|
||||
// Act
|
||||
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
|
||||
|
||||
// Assert
|
||||
expect(homeResponse.status).toBe(200);
|
||||
// Assert
|
||||
expect(homeResponse.status).toBe(200);
|
||||
} finally {
|
||||
await homarrContainer.stop();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { stringify } from "superjson";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const adminCredentials = {
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
};
|
||||
|
||||
describe("lazy widget application graph", () => {
|
||||
test("renders an authenticated board and preserves first-use UI behavior after reload", async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const { userId } = await seedAdminUserAsync(db, adminCredentials);
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
const sectionId = createId();
|
||||
const itemId = createId();
|
||||
|
||||
await db.insert(sqliteSchema.boards).values({
|
||||
id: boardId,
|
||||
name: "lazy-widget-board",
|
||||
creatorId: userId,
|
||||
});
|
||||
await db.insert(sqliteSchema.layouts).values({
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
boardId,
|
||||
columnCount: 12,
|
||||
});
|
||||
await db.insert(sqliteSchema.sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
await db.insert(sqliteSchema.items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringify({ showSeconds: true }),
|
||||
});
|
||||
await db.insert(sqliteSchema.itemLayouts).values({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 2,
|
||||
});
|
||||
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "credentials",
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const pageErrors: Error[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error));
|
||||
|
||||
try {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
|
||||
await expect(page.locator("[data-homarr-dev-benchmark-board]")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator(`[data-id="${itemId}"] .clock-widget-container`)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.keyboard.press("Control+K");
|
||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await page.evaluate(async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
return registrations[0]?.scope ?? null;
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(`${baseUrl}/`);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(`[data-id="${itemId}"] .clock-widget-container`)).toBeVisible({ timeout: 15_000 });
|
||||
expect(pageErrors).toEqual([]);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
+37
-34
@@ -37,45 +37,48 @@ describe("LLDAP authorization", () => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const onboardingActions = new OnboardingActions(page, db);
|
||||
await onboardingActions.skipOnboardingAsync({
|
||||
group: defaultCredentials.group,
|
||||
});
|
||||
try {
|
||||
const onboardingActions = new OnboardingActions(page, db);
|
||||
await onboardingActions.skipOnboardingAsync({
|
||||
group: defaultCredentials.group,
|
||||
});
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
|
||||
await page.getByLabel("Username").fill(defaultCredentials.username);
|
||||
await page.locator("#password").fill(defaultCredentials.password);
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
// Act
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(defaultCredentials.username);
|
||||
await page.locator("#password").fill(defaultCredentials.password);
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
|
||||
// Assert
|
||||
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
const users = await db.query.users.findMany({
|
||||
with: {
|
||||
groups: {
|
||||
with: {
|
||||
group: true,
|
||||
// Assert
|
||||
await page.waitForURL(baseUrl, { timeout: 60_000 });
|
||||
const users = await db.query.users.findMany({
|
||||
with: {
|
||||
groups: {
|
||||
with: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(users).toHaveLength(1);
|
||||
const user = users[0]!;
|
||||
expect(user).toEqual(
|
||||
expect.objectContaining({
|
||||
name: defaultCredentials.username,
|
||||
email: defaultCredentials.email,
|
||||
provider: "ldap",
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(users).toHaveLength(1);
|
||||
const user = users[0];
|
||||
if (!user) {
|
||||
throw new Error("Expected LDAP login to create one user");
|
||||
}
|
||||
expect(user).toEqual(
|
||||
expect.objectContaining({
|
||||
name: defaultCredentials.username,
|
||||
email: defaultCredentials.email,
|
||||
provider: "ldap",
|
||||
}),
|
||||
);
|
||||
|
||||
const groups = user.groups.map((g) => g.group.name);
|
||||
expect(groups).toContain(defaultCredentials.group);
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
await lldapContainer.stop();
|
||||
const groups = user.groups.map((g) => g.group.name);
|
||||
expect(groups).toContain(defaultCredentials.group);
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop(), lldapContainer.stop()]);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
|
||||
+34
-34
@@ -22,25 +22,25 @@ describe("Onboarding", () => {
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
@@ -66,22 +66,22 @@ describe("Onboarding", () => {
|
||||
const actions = new OnboardingActions(page, db);
|
||||
const assertions = new OnboardingAssertions(page, db);
|
||||
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processExternalGroupStepAsync({
|
||||
name: externalGroupName,
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.processExternalGroupStepAsync({
|
||||
name: externalGroupName,
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
|
||||
}
|
||||
|
||||
public async assertFinishStepVisibleAsync() {
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ events {
|
||||
http {
|
||||
server {
|
||||
listen 7575;
|
||||
listen [::]:7575;
|
||||
${NGINX_LISTEN_IPV6}
|
||||
|
||||
# Route websockets traffic to port 3001
|
||||
location /websockets {
|
||||
|
||||
+10
-2
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.73.0",
|
||||
"version": "1.74.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
"benchmark:build:docker": "node --experimental-strip-types scripts/benchmarks/docker-build.mts",
|
||||
"benchmark:compare": "node --experimental-strip-types scripts/benchmarks/benchmark-compare.mts",
|
||||
"benchmark:board-graph": "node --experimental-strip-types scripts/benchmarks/board-static-graph.mts",
|
||||
"benchmark:docker": "node --experimental-strip-types scripts/benchmarks/docker-runtime.mts",
|
||||
"benchmark:request-memory": "node --expose-gc --import tsx scripts/benchmarks/request-handler-memory.mts",
|
||||
"clean": "git clean -xdf node_modules",
|
||||
"clean:workspaces": "turbo clean",
|
||||
"ci:act:all": "bash scripts/ci/act.sh all",
|
||||
@@ -21,6 +26,7 @@
|
||||
"db:push": "pnpm -F db push:sqlite",
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts",
|
||||
"dev:cli": "go -C tools/homarr-dev run .",
|
||||
"dev:cli:install": "go -C tools/homarr-dev install .",
|
||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||
@@ -34,7 +40,7 @@
|
||||
"release": "semantic-release",
|
||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||
"start": "pnpm -F nextjs start",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||
@@ -45,6 +51,7 @@
|
||||
"jsonpath-plus": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@semantic-release/changelog": "catalog:",
|
||||
"@semantic-release/commit-analyzer": "catalog:",
|
||||
@@ -65,6 +72,7 @@
|
||||
"oxlint": "catalog:",
|
||||
"semantic-release": "catalog:",
|
||||
"testcontainers": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"turbo": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./board-server": "./src/board-server.ts",
|
||||
"./query-cache": "./src/query-cache.ts",
|
||||
"./server-settings-server": "./src/server-settings-server.ts",
|
||||
"./user-server": "./src/user-server.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./websocket": "./src/websocket.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { createRscTrpcContext } from "./rsc-context";
|
||||
import { boardRouter } from "./router/board";
|
||||
|
||||
const createBoardCallerAsync = async () => boardRouter.createCaller(await createRscTrpcContext());
|
||||
|
||||
export const getBoardByNameAsync = cache(async (name: string) => {
|
||||
const caller = await createBoardCallerAsync();
|
||||
return await caller.getBoardByName({ name });
|
||||
});
|
||||
|
||||
export const getHomeBoardAsync = cache(async () => {
|
||||
const caller = await createBoardCallerAsync();
|
||||
return await caller.getHomeBoard();
|
||||
});
|
||||
@@ -1,22 +1,54 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const queryCacheDefaultStaleTimeMs = 0;
|
||||
// RSC-prefetched data must survive the hydration pass without an immediate
|
||||
// duplicate fetch. Widget-specific intervals still control live refreshes.
|
||||
export const queryCacheDefaultStaleTimeMs = 30_000;
|
||||
export const queryCacheDefaultRefetchIntervalMs = 30_000;
|
||||
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
|
||||
export const queryCacheMaxValueBytes = 1024 * 1024;
|
||||
export const queryCacheStoragePrefix = "homarr-widget-query";
|
||||
export const queryCacheBuster = "v2-superjson";
|
||||
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||
|
||||
let activeQueryCacheBoardId: string | null = null;
|
||||
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
|
||||
export const setActiveQueryCacheBoardId = (boardId: string | null) => {
|
||||
activeQueryCacheBoardId = boardId;
|
||||
export const isWidgetDataTrpcPath = (path: string) => {
|
||||
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
|
||||
return widgetDataQueryPaths.has(path);
|
||||
};
|
||||
|
||||
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
|
||||
|
||||
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
// These supporting queries can be numerous on a dashboard. Give them a
|
||||
// predictable refresh policy instead of refetching all of them after every tab
|
||||
// focus/reconnect event. Metadata can refresh after a bounded stale window;
|
||||
// Docker state remains live on a single shared timer.
|
||||
export const dashboardSupportingQueryPolicies = [
|
||||
{
|
||||
queryKey: [["app", "byId"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["app", "byIds"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["integration", "byIds"]],
|
||||
refetchInterval: false,
|
||||
staleTime: queryCacheMetadataStaleTimeMs,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
{
|
||||
queryKey: [["docker", "getContainers"]],
|
||||
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||
const first = queryKey[0];
|
||||
@@ -26,11 +58,9 @@ const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||
return parts.length > 0 ? parts : null;
|
||||
};
|
||||
|
||||
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
|
||||
export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
|
||||
const path = getTrpcPathFromQueryKey(queryKey);
|
||||
if (!path) return false;
|
||||
|
||||
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
|
||||
|
||||
return persistableQueryPaths.has(path.join("."));
|
||||
return isWidgetDataTrpcPath(path.join("."));
|
||||
};
|
||||
@@ -24,7 +24,6 @@ export const appRouter = createTRPCRouter({
|
||||
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
|
||||
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
|
||||
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
|
||||
queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
|
||||
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
|
||||
@@ -173,7 +173,7 @@ export const appRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
|
||||
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL or custom URI scheme such as vscode://, or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -2004,9 +2004,12 @@ const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithW
|
||||
};
|
||||
|
||||
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId ?? ""),
|
||||
});
|
||||
const groupPermissionWhere = userId
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, userId)),
|
||||
)
|
||||
: eq(boardGroupPermissions.groupId, "");
|
||||
const board = await db.query.boards.findFirst({
|
||||
where,
|
||||
with: {
|
||||
@@ -2029,8 +2032,8 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
items: {
|
||||
with: {
|
||||
integrations: {
|
||||
with: {
|
||||
integration: true,
|
||||
columns: {
|
||||
integrationId: true,
|
||||
},
|
||||
},
|
||||
layouts: true,
|
||||
@@ -2044,7 +2047,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
},
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2092,7 +2095,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
layoutId: layout.layoutId,
|
||||
sectionId: layout.sectionId,
|
||||
})),
|
||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||
integrationIds: itemIntegrations.map((item) => item.integrationId),
|
||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||
}),
|
||||
|
||||
@@ -19,9 +19,12 @@ export const throwIfActionForbiddenAsync = async (
|
||||
permission: BoardPermission,
|
||||
) => {
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const groupPermissionWhere = session
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, session.user.id)),
|
||||
)
|
||||
: eq(boardGroupPermissions.groupId, "");
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: boardWhere,
|
||||
columns: {
|
||||
@@ -34,7 +37,7 @@ export const throwIfActionForbiddenAsync = async (
|
||||
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
|
||||
where: groupPermissionWhere,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
|
||||
|
||||
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
|
||||
import { createTRPCRouter, internalProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
|
||||
const queryCacheBoardInput = z.object({
|
||||
boardId: z.string().min(1),
|
||||
});
|
||||
|
||||
const queryCacheSetInput = queryCacheBoardInput.extend({
|
||||
value: z.string().max(queryCacheMaxValueBytes * 2),
|
||||
});
|
||||
|
||||
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
|
||||
};
|
||||
|
||||
export const queryCacheRouter = createTRPCRouter({
|
||||
setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
|
||||
await validateBoardAccessAsync(ctx, input.boardId);
|
||||
const userId = ctx.session?.user.id ?? "anonymous";
|
||||
return {
|
||||
stored: await setQueryCacheAsync(
|
||||
userId,
|
||||
input.boardId,
|
||||
input.value,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheMaxValueBytes,
|
||||
),
|
||||
};
|
||||
}),
|
||||
removeItem: internalProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
|
||||
await validateBoardAccessAsync(ctx, input.boardId);
|
||||
const userId = ctx.session?.user.id ?? "anonymous";
|
||||
await removeQueryCacheAsync(userId, input.boardId);
|
||||
}),
|
||||
});
|
||||
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user