perf: cut cold route compilation and unblock preview images (#6441)
This commit is contained in:
1 parent
622e1dcfa5
commit
b32ad82850
182 files changed
+11421
-2339
No files matched your search
+2
-1
@@ -17,7 +17,8 @@ coverage
|
|||||||
dev
|
dev
|
||||||
.build
|
.build
|
||||||
e2e
|
e2e
|
||||||
apps/docs
|
apps/docs/*
|
||||||
|
!apps/docs/package.json
|
||||||
apps/nextjs/.next
|
apps/nextjs/.next
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Lint, typecheck, and build affected workspaces
|
- name: Lint, typecheck, and build affected workspaces
|
||||||
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
|
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful --concurrency=2
|
||||||
|
|
||||||
- name: Validate workspace configuration
|
- name: Validate workspace configuration
|
||||||
run: pnpm lint:ws
|
run: pnpm lint:ws
|
||||||
|
|||||||
@@ -78,3 +78,4 @@ skills-lock.json
|
|||||||
apps/docs/.docusaurus
|
apps/docs/.docusaurus
|
||||||
apps/docs/build
|
apps/docs/build
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
benchmark-results
|
||||||
+18
-8
@@ -3,12 +3,18 @@
|
|||||||
FROM node:24.18.0-alpine AS base
|
FROM node:24.18.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
ARG TARGETPLATFORM
|
||||||
WORKDIR /app
|
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
|
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 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
|
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
|
||||||
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
||||||
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
# 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 \
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||||
pnpm config set store-dir /pnpm/store && \
|
pnpm config set store-dir /pnpm/store && \
|
||||||
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
|
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
|
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||||
|
|
||||||
COPY . .
|
# Install only from the fetched, committed lockfile so local and Docker builds
|
||||||
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
|
||||||
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
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 SKIP_ENV_VALIDATION='true'
|
||||||
ARG CI='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_TEAM,env=TURBO_TEAM \
|
||||||
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
|
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
|
||||||
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
|
--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
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ This guide will help you set up your development environment to start contributi
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
|
- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/).
|
||||||
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
|
- **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/).
|
- **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.
|
- **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/).
|
- **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
|
#### 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 -- 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 <name>`: Build the current checkout as `homarr:<name>`.
|
||||||
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
|
- `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 build`: Build the project for production.
|
||||||
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
|
- `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.
|
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
|
||||||
@@ -25,15 +25,13 @@ 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.
|
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 |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
|
||||||
| ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` |
|
| `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`` |
|
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||||
| ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` |
|
| `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` |
|
| `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` |
|
| `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` |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
@@ -43,9 +41,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.
|
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 |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| ----------------------- | ------------------------------------------- | ----------------------- | ------- |
|
||||||
| ``SECRET_ENCRYPTION_KEY`` | Secret used to encrypt secrets in database. | 64 character hex string | - |
|
| `SECRET_ENCRYPTION_KEY` | Secret used to encrypt secrets in database. | 64 character hex string | - |
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
A random secret can be generated by using the following command: `openssl rand -hex 32`
|
A random secret can be generated by using the following command: `openssl rand -hex 32`
|
||||||
@@ -53,27 +51,27 @@ A random secret can be generated by using the following command: `openssl rand -
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
|
||||||
| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
| `DOCKER_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_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` | - |
|
| `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
|
## Database
|
||||||
| Environment Variable | Description | Possible values | Default |
|
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| 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_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_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` |
|
| `DB_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` |
|
||||||
| ``DB_HOST`` | Database host to connect to. | Any valid database host | - |
|
| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` |
|
||||||
| ``DB_PORT`` | Database port to connect to. | Any valid database port | - |
|
| `DB_HOST` | Database host to connect to. | Any valid database host | - |
|
||||||
| ``DB_NAME`` | Database name to connect to. | Any valid database name | - |
|
| `DB_PORT` | Database port to connect to. | Any valid database port | - |
|
||||||
| ``DB_USER`` | Database user to connect with. | Any valid database user | - |
|
| `DB_NAME` | Database name to connect to. | Any valid database name | - |
|
||||||
| ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - |
|
| `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.
|
You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values.
|
||||||
|
|
||||||
|
|
||||||
## Redis
|
## Redis
|
||||||
|
|
||||||
By default Redis is running within the installation. (for example in Docker).
|
By default Redis is running within the installation. (for example in Docker).
|
||||||
@@ -83,25 +81,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
|
External redis is currently not supported with Proxmox Community Scripts
|
||||||
:::
|
:::
|
||||||
|
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------ | ------- |
|
||||||
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
|
| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
|
||||||
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
|
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
|
||||||
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
|
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
|
||||||
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
|
| `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_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_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 | - |
|
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
|
||||||
|
|
||||||
|
|
||||||
## Proxy
|
## Proxy
|
||||||
|
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| -------------------- | ------------------------------------------------------------------- | ------------------------- | ------- |
|
||||||
| ``HTTP_PROXY`` | HTTP proxy to use | Any valid HTTP proxy URL | - |
|
| `HTTP_PROXY` | HTTP proxy to use | Any valid HTTP proxy URL | - |
|
||||||
| ``HTTPS_PROXY`` | HTTPS proxy to use | Any valid HTTPS 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 | - |
|
| `NO_PROXY` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
|
||||||
|
|
||||||
|
|
||||||
## Advanced deployments
|
## Advanced deployments
|
||||||
|
|
||||||
@@ -109,6 +105,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.
|
The advanced deployments environment variables should only be used if you know what you are doing.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
| Environment Variable | Description |
|
| Environment Variable | Description |
|
||||||
| ------------------------ | ----------- |
|
| ------------------------ | -------------------------------------------------- |
|
||||||
| ``DB_MIGRATIONS_DISABLED`` | Disable db migrations. For example for helm charts |
|
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
|
||||||
+22
-11
@@ -1,4 +1,6 @@
|
|||||||
// Importing env files here to validate on build
|
// Importing env files here to validate on build
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import "@homarr/auth/env";
|
import "@homarr/auth/env";
|
||||||
import "@homarr/core/infrastructure/db/env";
|
import "@homarr/core/infrastructure/db/env";
|
||||||
import "@homarr/common/env";
|
import "@homarr/common/env";
|
||||||
@@ -13,7 +15,21 @@ const withNextIntl = createNextIntlPlugin({
|
|||||||
requestConfig: "../../packages/translation/src/request.ts",
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Next previews otherwise create agent instruction files in the application
|
||||||
|
// directory during development.
|
||||||
|
agentRules: false,
|
||||||
env: {
|
env: {
|
||||||
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
||||||
},
|
},
|
||||||
@@ -30,19 +46,14 @@ const nextConfig: NextConfig = {
|
|||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForBuild: true,
|
||||||
webpackMemoryOptimizations: true,
|
useTypeScriptCli: true,
|
||||||
},
|
},
|
||||||
turbopack: {
|
turbopack: {
|
||||||
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
|
root: path.resolve(import.meta.dirname, "../.."),
|
||||||
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
|
// Development runs tasks and WebSocket as separate processes. These aliases
|
||||||
// (see vercel/next.js#95125). Suppress until upstream fix lands.
|
// keep their production-only instrumentation imports out of the dev graph.
|
||||||
ignoreIssue: [
|
resolveAlias: getDevelopmentServiceAliases(),
|
||||||
{
|
|
||||||
path: "**/*",
|
|
||||||
title: "Encountered unexpected file in NFT list",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||||
images: {
|
images: {
|
||||||
|
|||||||
@@ -59,11 +59,10 @@
|
|||||||
"@modelcontextprotocol/sdk": "catalog:",
|
"@modelcontextprotocol/sdk": "catalog:",
|
||||||
"@scalar/api-reference-react": "catalog:",
|
"@scalar/api-reference-react": "catalog:",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tanstack/query-async-storage-persister": "catalog:",
|
"@tanstack/react-devtools": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-query-next-experimental": "catalog:",
|
"@tanstack/react-query-next-experimental": "catalog:",
|
||||||
"@tanstack/react-query-persist-client": "catalog:",
|
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/next": "catalog:",
|
"@trpc/next": "catalog:",
|
||||||
"@trpc/react-query": "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";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { QueryKey } from "@tanstack/react-query";
|
import dynamic from "next/dynamic";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
||||||
import {
|
import {
|
||||||
createWSClient,
|
createWSClient,
|
||||||
httpBatchStreamLink,
|
httpBatchStreamLink,
|
||||||
@@ -25,18 +23,24 @@ import { TRPCClientError } from "@trpc/client";
|
|||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import {
|
import {
|
||||||
isPersistableWidgetQueryKey,
|
dashboardSupportingQueryPolicies,
|
||||||
queryCacheBuster,
|
|
||||||
queryCacheDefaultGcTimeMs,
|
queryCacheDefaultGcTimeMs,
|
||||||
queryCacheDefaultRefetchIntervalMs,
|
queryCacheDefaultRefetchIntervalMs,
|
||||||
queryCacheDefaultStaleTimeMs,
|
queryCacheDefaultStaleTimeMs,
|
||||||
} from "@homarr/api/query-cache";
|
} from "@homarr/api/query-cache";
|
||||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { showWarningNotification } from "@homarr/notifications";
|
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 = () => {
|
const getWebSocketProtocol = () => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
@@ -59,21 +63,46 @@ const constructWebsocketUrl = () => {
|
|||||||
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const wsClient = createWSClient({
|
export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||||
url: constructWebsocketUrl(),
|
const { data: session } = useSession();
|
||||||
});
|
const sessionQueryScope = getSessionQueryScope(session);
|
||||||
|
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
return (
|
||||||
const [persister] = useState(() => createWidgetQueryPersister());
|
<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 [queryClient] = useState(() => {
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: queryCacheDefaultStaleTimeMs,
|
staleTime: queryCacheDefaultStaleTimeMs,
|
||||||
gcTime: queryCacheDefaultGcTimeMs,
|
gcTime: queryCacheDefaultGcTimeMs,
|
||||||
refetchOnWindowFocus: false,
|
retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3),
|
||||||
refetchOnReconnect: false,
|
|
||||||
retry: 3,
|
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
onError(error) {
|
onError(error) {
|
||||||
@@ -91,23 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
client.setQueryDefaults([["widget"]], {
|
||||||
for (const { definition } of Object.values(widgetImports)) {
|
refetchInterval: queryCacheDefaultRefetchIntervalMs,
|
||||||
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
|
refetchOnWindowFocus: false,
|
||||||
if (def.refetchInterval === undefined) continue;
|
refetchOnReconnect: false,
|
||||||
const key = def.queryKey ?? [["widget", def.kind]];
|
});
|
||||||
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
|
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
||||||
client.setQueryDefaults(key, { refetchInterval: interval });
|
client.setQueryDefaults(queryKey, {
|
||||||
|
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
|
||||||
|
client.setQueryDefaults(queryKey, policy);
|
||||||
}
|
}
|
||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [trpcClient] = useState(() => {
|
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||||
|
|
||||||
|
const trpcClient = useMemo(() => {
|
||||||
return clientApi.createClient({
|
return clientApi.createClient({
|
||||||
links: [
|
links: [
|
||||||
loggerLink({
|
loggerLink({
|
||||||
enabled: (opts) =>
|
enabled: (opts) => opts.direction === "down" && opts.result instanceof Error,
|
||||||
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
|
||||||
}),
|
}),
|
||||||
splitLink({
|
splitLink({
|
||||||
condition: ({ type }) => type === "subscription",
|
condition: ({ type }) => type === "subscription",
|
||||||
@@ -147,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
}, [wsClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<PersistQueryClientProvider
|
<QueryClientProvider client={queryClient}>
|
||||||
client={queryClient}
|
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||||
persistOptions={{
|
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||||
persister,
|
</QueryClientProvider>
|
||||||
buster: queryCacheBuster,
|
|
||||||
maxAge: queryCacheDefaultGcTimeMs,
|
|
||||||
dehydrateOptions: {
|
|
||||||
shouldDehydrateQuery: (query) =>
|
|
||||||
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
|
||||||
</PersistQueryClientProvider>
|
|
||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { api } from "@homarr/api/server";
|
import { getHomeBoardAsync } from "@homarr/api/board-server";
|
||||||
|
|
||||||
import { createBoardContentPage } from "../_creator";
|
import { createBoardContentPage } from "../_creator";
|
||||||
|
|
||||||
export default createBoardContentPage<{ locale: string }>({
|
export default createBoardContentPage<{ locale: string }>({
|
||||||
async getInitialBoardAsync() {
|
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";
|
import { createBoardContentPage } from "../../_creator";
|
||||||
|
|
||||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||||
async getInitialBoardAsync({ name }) {
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,10 +14,10 @@ export const ClientBoard = () => {
|
|||||||
|
|
||||||
const fullWidthSortedSections = board.sections
|
const fullWidthSortedSections = board.sections
|
||||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||||
<BoardBackgroundVideo />
|
<BoardBackgroundVideo />
|
||||||
<Stack h="100%">
|
<Stack h="100%">
|
||||||
{fullWidthSortedSections.map((section) =>
|
{fullWidthSortedSections.map((section) =>
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { cache } from "react";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
import { makeQueryClient } from "@homarr/api/shared";
|
||||||
import superjson from "superjson";
|
|
||||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
|
||||||
import { getQueryClient } from "@homarr/api/server";
|
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||||
@@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { getQueryCacheAsync } from "@homarr/redis";
|
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||||
|
|
||||||
@@ -29,6 +25,7 @@ import { DynamicClientBoard } from "./_dynamic-client";
|
|||||||
import { BoardContentHeaderActions } from "./_header-actions";
|
import { BoardContentHeaderActions } from "./_header-actions";
|
||||||
|
|
||||||
const logger = createLogger({ module: "createBoardContentPage" });
|
const logger = createLogger({ module: "createBoardContentPage" });
|
||||||
|
const getQueryClient = cache(makeQueryClient);
|
||||||
|
|
||||||
export type Params = Record<string, unknown>;
|
export type Params = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -61,7 +58,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, new Map<WidgetKind, Item[]>());
|
}, new Map<WidgetKind, Item[]>());
|
||||||
|
|
||||||
const [integrations] = await Promise.all([
|
const [integrations] = await Promise.all([
|
||||||
getIntegrationsWithPermissionsAsync(session),
|
getIntegrationsWithPermissionsAsync(session),
|
||||||
...Array.from(itemsMap).map(([kind, items]) =>
|
...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 (
|
return (
|
||||||
<>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<Suspense>
|
<IntegrationProvider integrations={integrations}>
|
||||||
<QueryCacheHydration userId={userId} boardId={board.id} />
|
<DynamicClientBoard />
|
||||||
</Suspense>
|
</IntegrationProvider>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
</HydrationBoundary>
|
||||||
<IntegrationProvider integrations={integrations}>
|
|
||||||
<DynamicClientBoard />
|
|
||||||
</IntegrationProvider>
|
|
||||||
</HydrationBoundary>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
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";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
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), {
|
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
loading: BoardLoadingShell,
|
||||||
});
|
});
|
||||||
@@ -1,45 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||||
IconBox,
|
|
||||||
IconBoxAlignTop,
|
|
||||||
IconChevronDown,
|
|
||||||
IconLayoutBoard,
|
|
||||||
IconPencil,
|
|
||||||
IconPencilOff,
|
|
||||||
IconPlug,
|
|
||||||
IconPlus,
|
|
||||||
IconReplace,
|
|
||||||
IconResize,
|
|
||||||
IconSettings,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { hotkeys } from "@homarr/definitions";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { AppSelectModal } from "@homarr/modals-collection";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
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 { 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 { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
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 }) => {
|
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
@@ -52,16 +43,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditMode && <AddMenu />}
|
{isEditMode && <BoardAddMenu />}
|
||||||
|
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||||
|
|
||||||
{!demoReadOnly && (
|
{!demoReadOnly && (
|
||||||
<OnboardingTour.Target id="board-settings">
|
<TourTarget id="board-settings">
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||||
<IconSettings stroke={1.5} />
|
<IconSettings stroke={1.5} />
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</OnboardingTour.Target>
|
</TourTarget>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectBoardsMenu />
|
<SelectBoardsMenu />
|
||||||
@@ -69,94 +60,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 }: { demoReadOnly: boolean }) => {
|
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||||
const [isEditMode, { open, close }] = useEditMode();
|
const [isEditMode, { open, close }] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
@@ -197,21 +100,29 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
|||||||
usePreventLeaveWithDirty(isEditMode);
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingTour.Target id="board-edit-mode">
|
<TourTarget id="board-edit-mode">
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
<HeaderButton
|
||||||
|
onClick={toggle}
|
||||||
|
loading={isPending}
|
||||||
|
onFocus={preloadBoardAddMenu}
|
||||||
|
onPointerEnter={preloadBoardAddMenu}
|
||||||
|
>
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</OnboardingTour.Target>
|
</TourTarget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SelectBoardsMenu = () => {
|
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 (
|
return (
|
||||||
<OnboardingTour.Target id="board-switcher">
|
<TourTarget id="board-switcher">
|
||||||
<Box>
|
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<HeaderButton w="auto" px={4}>
|
<HeaderButton w="auto" px={4}>
|
||||||
<IconReplace stroke={1.5} />
|
<IconReplace stroke={1.5} />
|
||||||
@@ -219,6 +130,11 @@ const SelectBoardsMenu = () => {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||||
<ScrollArea.Autosize mah={300}>
|
<ScrollArea.Autosize mah={300}>
|
||||||
|
{isPending && (
|
||||||
|
<Center p="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
{boards.map((board) => (
|
{boards.map((board) => (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={board.id}
|
key={board.id}
|
||||||
@@ -233,7 +149,7 @@ const SelectBoardsMenu = () => {
|
|||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
</OnboardingTour.Target>
|
</TourTarget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,15 +162,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDirty) return;
|
if (!isDirty) return;
|
||||||
|
|
||||||
const handleClick = (event: Event) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
const target = (event.target as HTMLElement).closest("a");
|
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) {
|
if (!target) return;
|
||||||
console.warn("No anchor element found for click event", event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: t("board.action.edit.confirmLeave.title"),
|
title: t("board.action.edit.confirmLeave.title"),
|
||||||
@@ -280,19 +194,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|||||||
event.returnValue = true;
|
event.returnValue = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const anchors = document.querySelectorAll(anchorSelector);
|
document.addEventListener("click", handleClick, true);
|
||||||
anchors.forEach((link) => {
|
|
||||||
link.addEventListener("click", handleClick);
|
|
||||||
});
|
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState);
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
anchors.forEach((link) => {
|
document.removeEventListener("click", handleClick, true);
|
||||||
link.removeEventListener("click", handleClick);
|
|
||||||
});
|
|
||||||
window.removeEventListener("popstate", handlePopState);
|
window.removeEventListener("popstate", handlePopState);
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
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 { BoardOtherHeaderActions } from "../_header-actions";
|
||||||
import { createBoardLayout } from "../_layout-creator";
|
import { createBoardLayout } from "../_layout-creator";
|
||||||
@@ -6,6 +6,6 @@ import { createBoardLayout } from "../_layout-creator";
|
|||||||
export default createBoardLayout<{ locale: string; name: string }>({
|
export default createBoardLayout<{ locale: string; name: string }>({
|
||||||
headerActions: <BoardOtherHeaderActions />,
|
headerActions: <BoardOtherHeaderActions />,
|
||||||
async getInitialBoardAsync({ name }) {
|
async getInitialBoardAsync({ name }) {
|
||||||
return await api.board.getBoardByName({ name });
|
return await getBoardByNameAsync(name);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import { AppShellMain } from "@mantine/core";
|
import { AppShellMain } from "@mantine/core";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { BoardProvider } from "@homarr/boards/context";
|
import { BoardProvider } from "@homarr/boards/context";
|
||||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||||
@@ -11,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
|||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
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 { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
@@ -21,11 +23,6 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "createBoardLayout" });
|
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> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
@@ -43,8 +40,32 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
params: Promise<TParams>;
|
params: Promise<TParams>;
|
||||||
}>) => {
|
}>) => {
|
||||||
const session = await auth();
|
const resolvedParams = await params;
|
||||||
const initialBoard = await getInitialBoard(await params).catch((error) => {
|
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 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, shouldRunBoardTour] = await Promise.all([
|
||||||
|
sessionPromise,
|
||||||
|
initialBoardPromise,
|
||||||
|
colorSchemePromise,
|
||||||
|
shouldRunBoardTourPromise,
|
||||||
|
]);
|
||||||
|
if (initialBoardResult.status === "rejected") {
|
||||||
|
const { error } = initialBoardResult;
|
||||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.debug("No home board found for anonymous user, redirecting to login");
|
logger.debug("No home board found for anonymous user, redirecting to login");
|
||||||
@@ -60,8 +81,8 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
});
|
}
|
||||||
const colorScheme = await getCurrentColorSchemeAsync();
|
const initialBoard = initialBoardResult.board;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
@@ -69,7 +90,7 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
<EditModeProvider>
|
<EditModeProvider>
|
||||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<CustomCss />
|
<CustomCss />
|
||||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||||
<ClientShell hasNavigation={false}>
|
<ClientShell hasNavigation={false}>
|
||||||
<MainHeader
|
<MainHeader
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
@@ -78,7 +99,7 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
/>
|
/>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
</ClientShell>
|
</ClientShell>
|
||||||
</BoardTourWrapper>
|
</BoardTourGate>
|
||||||
</BoardMantineProvider>
|
</BoardMantineProvider>
|
||||||
</EditModeProvider>
|
</EditModeProvider>
|
||||||
</BoardReadyProvider>
|
</BoardReadyProvider>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
import type { MaybePromise } from "@homarr/common/types";
|
import type { MaybePromise } from "@homarr/common/types";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
import type { OnboardingStep } from "@homarr/definitions";
|
import type { OnboardingStep } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -29,9 +29,16 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
|||||||
finish: InitFinish,
|
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() {
|
export default async function InitPage() {
|
||||||
const t = await getScopedI18n("init.step");
|
const t = await getScopedI18n("init.step");
|
||||||
const currentStep = await api.onboard.currentStep();
|
const currentStep = await getCurrentOnboardingStepAsync();
|
||||||
|
|
||||||
const CurrentComponent = stepComponents[currentStep.current];
|
const CurrentComponent = stepComponents[currentStep.current];
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import { notFound } from "next/navigation";
|
|||||||
import type { DayOfWeek } from "@mantine/dates";
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
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 { env } from "@homarr/auth/env";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { db } from "@homarr/db";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
|
||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { Notifications } from "@homarr/notifications";
|
import { Notifications } from "@homarr/notifications";
|
||||||
import { SettingsProvider } from "@homarr/settings";
|
import { SettingsProvider } from "@homarr/settings";
|
||||||
@@ -43,6 +43,8 @@ const fontSans = Inter({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "rootLayout" });
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
@@ -78,15 +80,27 @@ export default async function Layout(props: {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ locale: SupportedLanguage }>;
|
params: Promise<{ locale: SupportedLanguage }>;
|
||||||
}) {
|
}) {
|
||||||
if (!isLocaleSupported((await props.params).locale)) {
|
const { locale } = await props.params;
|
||||||
|
if (!isLocaleSupported(locale)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const sessionPromise = auth();
|
||||||
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
const userPromise = sessionPromise.then((session) =>
|
||||||
const serverSettings = await getServerSettingsAsync(db);
|
session
|
||||||
const colorScheme = await getCurrentColorSchemeAsync();
|
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
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([
|
const StackedProvider = composeWrappers([
|
||||||
(innerProps) => {
|
(innerProps) => {
|
||||||
@@ -125,8 +139,6 @@ export default async function Layout(props: {
|
|||||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { locale } = await props.params;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||||
<html
|
<html
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||||
import { env } from "@homarr/docker/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 type { NavigationLink } from "~/components/layout/navigation";
|
||||||
import { MainNavigation } from "~/components/layout/navigation";
|
import { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
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) {
|
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||||
const t = await getScopedI18n("management.navbar");
|
const sessionPromise = auth();
|
||||||
const session = await 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[] = [
|
const navigationLinks: NavigationLink[] = [
|
||||||
{
|
{
|
||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
@@ -225,7 +245,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
</ClientShell>
|
</ClientShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) return shell;
|
return (
|
||||||
|
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
|
||||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
{shell}
|
||||||
|
</ManageTourGate>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+3
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconRefresh } from "@tabler/icons-react";
|
import { IconRefresh } from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
export const ResetTours = () => {
|
export const ResetTours = () => {
|
||||||
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
|
||||||
|
const router = useRouter();
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||||
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
|
||||||
@@ -18,6 +20,7 @@ export const ResetTours = () => {
|
|||||||
message: t("resetSuccess.message"),
|
message: t("resetSuccess.message"),
|
||||||
});
|
});
|
||||||
void utils.user.getTourStatus.invalidate();
|
void utils.user.getTourStatus.invalidate();
|
||||||
|
router.refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { use, useCallback, useState } from "react";
|
||||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
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 { useModalAction } from "@homarr/modals";
|
||||||
import { showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
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 type { Dimensions } from "./_dimension-modal";
|
||||||
import { PreviewDimensionsModal } from "./_dimension-modal";
|
import { PreviewDimensionsModal } from "./_dimension-modal";
|
||||||
|
|
||||||
interface WidgetPreviewPageContentProps {
|
interface WidgetPreviewPageContentProps {
|
||||||
kind: WidgetKind;
|
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 settings = useSettings();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
const utils = clientApi.useUtils();
|
||||||
|
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
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 [editMode, setEditMode] = useState(false);
|
||||||
|
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||||
width: 128,
|
width: 128,
|
||||||
height: 128,
|
height: 128,
|
||||||
@@ -45,7 +42,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
integrationIds: string[];
|
integrationIds: string[];
|
||||||
advancedOptions: BoardItemAdvancedOptions;
|
advancedOptions: BoardItemAdvancedOptions;
|
||||||
}>({
|
}>({
|
||||||
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}),
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
advancedOptions: {
|
advancedOptions: {
|
||||||
title: null,
|
title: null,
|
||||||
@@ -54,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenEditWidgetModal = useCallback(() => {
|
const handleOpenEditWidgetModal = useCallback(async () => {
|
||||||
openWidgetEditModal(
|
const hasIntegrationSupport = "supportedIntegrations" in currentDefinition;
|
||||||
{
|
setIsEditorLoading(true);
|
||||||
kind,
|
preloadWidgetEditModal();
|
||||||
value: state,
|
try {
|
||||||
onSuccessfulEdit: (value) => {
|
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||||
setState(value);
|
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) =>
|
title(translate) {
|
||||||
"supportedIntegrations" in currentDefinition &&
|
return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`;
|
||||||
(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`)}`;
|
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
} catch (error) {
|
||||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
showErrorNotification({
|
||||||
|
title: t("common.error"),
|
||||||
const Comp = loadWidgetDynamic(kind);
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsEditorLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||||
|
|
||||||
const toggleEditMode = useCallback(() => {
|
const toggleEditMode = useCallback(() => {
|
||||||
setEditMode((editMode) => !editMode);
|
setEditMode((editMode) => !editMode);
|
||||||
@@ -94,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
});
|
});
|
||||||
}, [dimensions, openPreviewDimensionsModal]);
|
}, [dimensions, openPreviewDimensionsModal]);
|
||||||
|
|
||||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
const updateOptions = useCallback(
|
||||||
setState({ ...state, options: { ...state.options, newOptions } });
|
({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||||
|
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -105,10 +115,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={reset}
|
onReset={reset}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
<WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Comp
|
<Component
|
||||||
options={state.options as never}
|
options={state.options as never}
|
||||||
integrationIds={state.integrationIds}
|
integrationIds={state.integrationIds}
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
@@ -127,7 +137,10 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
|||||||
size={48}
|
size={48}
|
||||||
variant="default"
|
variant="default"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={handleOpenEditWidgetModal}
|
onClick={() => void handleOpenEditWidgetModal()}
|
||||||
|
onFocus={preloadWidgetEditModal}
|
||||||
|
onPointerEnter={preloadWidgetEditModal}
|
||||||
|
loading={isEditorLoading}
|
||||||
aria-label={t("common.action.edit")}
|
aria-label={t("common.action.edit")}
|
||||||
>
|
>
|
||||||
<IconPencil size={24} />
|
<IconPencil size={24} />
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
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 { MainNavigation } from "~/components/layout/navigation";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
|
|
||||||
const getLinks = () => {
|
const links = widgetKinds.map((kind) => ({
|
||||||
return Object.entries(widgetImports).map(([key, value]) => {
|
href: `/widgets/${kind}`,
|
||||||
return {
|
icon: widgetCatalogIcons[kind],
|
||||||
href: `/widgets/${key}`,
|
label: kind,
|
||||||
icon: value.definition.icon,
|
}));
|
||||||
label: value.definition.kind,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
|
||||||
const links = getLinks();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientShell hasHeader={false}>
|
<ClientShell hasHeader={false}>
|
||||||
<MainNavigation links={links} />
|
<MainNavigation links={links} />
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import { notFound } from "next/navigation";
|
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 type { WidgetKind } from "@homarr/definitions";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetKinds } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import { WidgetPreviewPageContent } from "./_content";
|
import { WidgetPreviewPageContent } from "./_content";
|
||||||
|
|
||||||
@@ -13,24 +12,16 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function WidgetPreview(props: 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();
|
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 (
|
return (
|
||||||
<Center h="100vh">
|
<Center h="100vh">
|
||||||
<WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
|
<Suspense fallback={<Loader size="sm" />}>
|
||||||
|
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
|
||||||
|
</Suspense>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import type { MutableRefObject } from "react";
|
import type { ComponentType, MutableRefObject } from "react";
|
||||||
import { useRef } from "react";
|
import { Suspense, use, useRef } from "react";
|
||||||
import { Badge, Card } from "@mantine/core";
|
import type { CardProps } from "@mantine/core";
|
||||||
|
import { Badge, Card, Center, Loader } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||||
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
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 type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import classes from "../sections/item.module.css";
|
import classes from "../sections/item.module.css";
|
||||||
@@ -19,7 +23,7 @@ import { useItemActions } from "./item-actions";
|
|||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { BoardItemMenu } from "./item-menu";
|
import { BoardItemMenu } from "./item-menu";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||||
|
|
||||||
interface BoardItemContentProps {
|
interface BoardItemContentProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
@@ -31,6 +35,53 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
|||||||
return undefined;
|
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) => {
|
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
@@ -38,29 +89,28 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
<ErrorBoundary
|
||||||
<Card
|
resetKeys={[item.kind]}
|
||||||
ref={ref}
|
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||||
className={combineClasses(
|
>
|
||||||
classes.itemCard,
|
<Suspense
|
||||||
`${item.kind}-wrapper`,
|
fallback={
|
||||||
"grid-stack-item-content",
|
<BoardItemCard item={item} innerRef={ref}>
|
||||||
item.advancedOptions.customCssClasses.join(" "),
|
<Center h="100%">
|
||||||
)}
|
<Loader size="sm" />
|
||||||
radius={board.itemRadius}
|
</Center>
|
||||||
styles={{
|
</BoardItemCard>
|
||||||
root: {
|
}
|
||||||
"--opacity": board.opacity / 100,
|
|
||||||
containerType: "size",
|
|
||||||
overflow: getOverflowFromKind(item.kind),
|
|
||||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
p={0}
|
|
||||||
>
|
>
|
||||||
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
<LoadedBoardItemContent
|
||||||
</Card>
|
item={item}
|
||||||
</WidgetContextMenu>
|
width={width}
|
||||||
|
height={height}
|
||||||
|
widgetStateRef={widgetStateRef}
|
||||||
|
innerRef={ref}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
{item.advancedOptions.title?.trim() && (
|
{item.advancedOptions.title?.trim() && (
|
||||||
<Badge
|
<Badge
|
||||||
pos="absolute"
|
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 {
|
interface InnerContentProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
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 settings = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const Comp = loadWidgetDynamic(item.kind);
|
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
||||||
const { definition } = widgetImports[item.kind];
|
|
||||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
|
||||||
const newItem = { ...item, options };
|
const newItem = { ...item, options };
|
||||||
const { updateItemOptions } = useItemActions();
|
const { updateItemOptions } = useItemActions();
|
||||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||||
updateItemOptions({ itemId: item.id, newOptions });
|
updateItemOptions({ itemId: item.id, newOptions });
|
||||||
const widgetSupportsIntegrations =
|
const widgetSupportsIntegrations =
|
||||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryErrorResetBoundary>
|
<QueryErrorResetBoundary>
|
||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
removePersistedWidgetQueries(queryClient);
|
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
<>
|
<>
|
||||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
<BoardItemMenu
|
||||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
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)
|
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<BoardItemMenu offset={4} item={newItem} />
|
<BoardItemMenu offset={4} item={newItem} definition={definition} />
|
||||||
<Comp
|
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
|
||||||
options={options as never}
|
<Component
|
||||||
integrationIds={item.integrationIds}
|
options={options as never}
|
||||||
isEditMode={isEditMode}
|
integrationIds={item.integrationIds}
|
||||||
boardId={board.id}
|
isEditMode={isEditMode}
|
||||||
itemId={item.id}
|
boardId={board.id}
|
||||||
setOptions={(partialNewOptions) =>
|
itemId={item.id}
|
||||||
updateOptions({
|
setOptions={(partialNewOptions) =>
|
||||||
newOptions: {
|
updateOptions({
|
||||||
...options,
|
newOptions: {
|
||||||
...partialNewOptions.newOptions,
|
...options,
|
||||||
},
|
...partialNewOptions.newOptions,
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
{...dimensions}
|
}
|
||||||
/>
|
{...dimensions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</QueryErrorResetBoundary>
|
</QueryErrorResetBoundary>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ActionIcon, Menu } from "@mantine/core";
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
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 { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import { widgetImports } from "@homarr/widgets";
|
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useSectionContext } from "../sections/section-context";
|
import { useSectionContext } from "../sections/section-context";
|
||||||
import { useItemActions } from "./item-actions";
|
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 {
|
interface BoardItemMenuProps {
|
||||||
offset: number;
|
offset: number;
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
|
definition: WidgetDefinition;
|
||||||
resetErrorBoundary?: () => void;
|
resetErrorBoundary?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
if (!session) return null;
|
const [isEditMode] = useEditMode();
|
||||||
|
if (!session || !isEditMode) return null;
|
||||||
|
|
||||||
return <BoardItemMenuInner {...props} />;
|
return <BoardItemMenuInner {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
|
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||||
const tItem = useScopedI18n("item");
|
const tItem = useScopedI18n("item");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal } = useModalAction(WidgetEditModal);
|
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||||
const { openConfirmModal } = useConfirmModal();
|
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 } =
|
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||||
useItemActions();
|
useItemActions();
|
||||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
enabled: isMenuOpen && hasSupportedIntegrations,
|
||||||
|
});
|
||||||
const { gridstack } = useSectionContext().refs;
|
const { gridstack } = useSectionContext().refs;
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
|
||||||
@@ -52,12 +57,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
}
|
}
|
||||||
}, [item, resetErrorBoundary]);
|
}, [item, resetErrorBoundary]);
|
||||||
|
|
||||||
if (!isEditMode || isPending) return null;
|
|
||||||
|
|
||||||
const openEditModal = () => {
|
const openEditModal = () => {
|
||||||
openModal(
|
openModal(
|
||||||
{
|
{
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
|
definition,
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: item.advancedOptions,
|
advancedOptions: item.advancedOptions,
|
||||||
options: item.options,
|
options: item.options,
|
||||||
@@ -80,10 +84,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
},
|
},
|
||||||
integrationData: (integrationData ?? []).filter(
|
integrationData: (integrationData ?? []).filter(
|
||||||
(integration) =>
|
(integration) =>
|
||||||
"supportedIntegrations" in currentDefinition &&
|
"supportedIntegrations" in definition &&
|
||||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||||
),
|
),
|
||||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
integrationSupport: "supportedIntegrations" in definition,
|
||||||
settings,
|
settings,
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
@@ -107,7 +111,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -123,11 +127,19 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown miw={128}>
|
<Menu.Dropdown miw={128}>
|
||||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
<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")}
|
{tItem("action.edit")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconLayoutKanban size={16} />}
|
leftSection={<IconLayoutKanban size={16} />}
|
||||||
|
onFocus={preloadItemMoveModal}
|
||||||
|
onPointerEnter={preloadItemMoveModal}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!gridstack.current) return;
|
if (!gridstack.current) return;
|
||||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
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";
|
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
interface InnerProps {
|
export interface ItemMoveModalProps {
|
||||||
gridStack: GridStack;
|
gridStack: GridStack;
|
||||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
export const ItemMoveModal = createModal<ItemMoveModalProps>(({ actions, innerProps }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||||
const form = useZodForm(
|
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 { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import { IconApi } from "@tabler/icons-react";
|
import { IconApi } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createId, objectEntries } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||||
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import { useItemActions } from "./item-actions";
|
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 [search, setSearch] = useState("");
|
||||||
|
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||||
|
const selectionLock = useRef(false);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
|
||||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
|
||||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() =>
|
() =>
|
||||||
objectEntries(widgetImports)
|
widgetKinds
|
||||||
.filter(([kind]) => kind !== "customApi")
|
.filter((kind) => kind !== "customApi")
|
||||||
.map(([kind, value]) => ({
|
.map((kind) => {
|
||||||
kind,
|
return {
|
||||||
supportedIntegrations:
|
kind,
|
||||||
"supportedIntegrations" in value.definition
|
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
|
||||||
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
(integration) => integration !== "mock",
|
||||||
: [],
|
),
|
||||||
icon: value.definition.icon,
|
icon: widgetCatalogIcons[kind],
|
||||||
name: t(`widget.${kind}.name`),
|
name: t(`widget.${kind}.name`),
|
||||||
description: t(`widget.${kind}.description`),
|
description: t(`widget.${kind}.description`),
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
.sort((itemA, itemB) => {
|
.sort((itemA, itemB) => {
|
||||||
if (itemA.kind === "app") return -1;
|
if (itemA.kind === "app") return -1;
|
||||||
if (itemB.kind === "app") return 1;
|
if (itemB.kind === "app") return 1;
|
||||||
@@ -66,52 +85,81 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
[customWidgetDefs, search],
|
[customWidgetDefs, search],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddCustomWidget = (definitionId: string) => {
|
const notifyDefinitionLoadError = (error: unknown) => {
|
||||||
const itemId = createId();
|
showErrorNotification({
|
||||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
title: t("common.error"),
|
||||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
message: error instanceof Error ? error.message : String(error),
|
||||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
});
|
||||||
actions.closeModal();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = (kind: WidgetKind) => {
|
const handleAddCustomWidget = async (definitionId: string) => {
|
||||||
const definition = widgetImports[kind].definition;
|
if (!tryLockSelection(selectionLock)) return;
|
||||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
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
|
const handleAdd = async (kind: WidgetKind) => {
|
||||||
? (integrationData ?? []).filter((integration) =>
|
if (!tryLockSelection(selectionLock)) return;
|
||||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
setLoadingSelection(kind);
|
||||||
)
|
preloadWidgetEditModal();
|
||||||
: [];
|
try {
|
||||||
|
const definition = await loadWidgetDefinition(kind);
|
||||||
|
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||||
|
|
||||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
|
||||||
const itemId = createId();
|
hasIntegrationSupport,
|
||||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
supportedIntegrations: definition.supportedIntegrations ?? [],
|
||||||
|
currentData: integrationData,
|
||||||
|
ensureDataAsync: ensureIntegrationDataAsync,
|
||||||
|
});
|
||||||
|
|
||||||
createItem({ id: itemId, kind, integrationIds });
|
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||||
actions.closeModal();
|
const itemId = createId();
|
||||||
|
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||||
|
|
||||||
openEditModal(
|
createItem({ id: itemId, kind, integrationIds });
|
||||||
{
|
actions.closeModal();
|
||||||
kind,
|
|
||||||
value: {
|
openEditModal(
|
||||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
{
|
||||||
options: defaultOptions,
|
kind,
|
||||||
integrationIds,
|
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 });
|
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
|
||||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
|
||||||
},
|
},
|
||||||
integrationData: matchingIntegrations,
|
);
|
||||||
integrationSupport: hasIntegrationSupport,
|
} catch (error) {
|
||||||
settings,
|
notifyDefinitionLoadError(error);
|
||||||
},
|
} finally {
|
||||||
{
|
unlockSelection(selectionLock);
|
||||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
setLoadingSelection(null);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,9 +168,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
placeholder={`${t("item.create.search")}...`}
|
placeholder={`${t("item.create.search")}...`}
|
||||||
onSearchKeyDown={(event) => {
|
onSearchKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
const [item] = filteredItems;
|
||||||
handleAdd(filteredItems[0]!.kind);
|
if (item) void handleAdd(item.kind);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -130,7 +178,13 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
<WidgetItem
|
<WidgetItem
|
||||||
key={item.kind}
|
key={item.kind}
|
||||||
item={item}
|
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))}
|
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -146,12 +200,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
{filteredCustomWidgets.map((def) => (
|
{filteredCustomWidgets.map((def) => (
|
||||||
<Card
|
<Card
|
||||||
key={def.id}
|
key={def.id}
|
||||||
|
className={classes.card}
|
||||||
h={selectGridCardHeight}
|
h={selectGridCardHeight}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
style={{ overflow: "hidden" }}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
|
||||||
>
|
>
|
||||||
<Stack h="100%" gap="xs">
|
<Stack h="100%" gap="xs">
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
@@ -169,18 +223,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box
|
<Box
|
||||||
|
className={classes.action}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
bottom={0}
|
bottom={0}
|
||||||
left={0}
|
left={0}
|
||||||
right={0}
|
right={0}
|
||||||
p="xs"
|
p="xs"
|
||||||
style={{
|
style={{
|
||||||
opacity: "var(--_hover-opacity)",
|
|
||||||
transition: "opacity 150ms ease",
|
|
||||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
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")}
|
{t("item.create.addToBoard")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -196,7 +256,24 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
)}
|
)}
|
||||||
</SelectGridLayout>
|
</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"),
|
defaultTitle: (t) => t("item.create.title"),
|
||||||
size: modalSizeSelect,
|
size: modalSizeSelect,
|
||||||
});
|
});
|
||||||
@@ -204,6 +281,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
const WidgetItem = ({
|
const WidgetItem = ({
|
||||||
item,
|
item,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onIntent,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
hasMatchingIntegration,
|
hasMatchingIntegration,
|
||||||
}: {
|
}: {
|
||||||
item: {
|
item: {
|
||||||
@@ -214,23 +294,27 @@ const WidgetItem = ({
|
|||||||
icon: TablerIcon;
|
icon: TablerIcon;
|
||||||
};
|
};
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
onIntent: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
hasMatchingIntegration: boolean;
|
hasMatchingIntegration: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
className={classes.card}
|
||||||
h={selectGridCardHeight}
|
h={selectGridCardHeight}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{
|
style={{
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
"--_hover-opacity": "0",
|
|
||||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
onFocus={onIntent}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
onPointerEnter={onIntent}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
>
|
>
|
||||||
<Stack h="100%" gap="xs">
|
<Stack h="100%" gap="xs">
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
@@ -247,18 +331,17 @@ const WidgetItem = ({
|
|||||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box
|
<Box
|
||||||
|
className={classes.action}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
bottom={0}
|
bottom={0}
|
||||||
left={0}
|
left={0}
|
||||||
right={0}
|
right={0}
|
||||||
p="xs"
|
p="xs"
|
||||||
style={{
|
style={{
|
||||||
opacity: "var(--_hover-opacity)",
|
|
||||||
transition: "opacity 150ms ease",
|
|
||||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
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`)}
|
{t(`item.create.addToBoard`)}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import type { MutableRefObject, ReactNode } from "react";
|
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 { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
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 { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -25,63 +25,116 @@ import { useSettings } from "@homarr/settings";
|
|||||||
import { translateIfNecessary } from "@homarr/translation";
|
import { translateIfNecessary } from "@homarr/translation";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition";
|
||||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useSectionContext } from "../sections/section-context";
|
import { useSectionContext } from "../sections/section-context";
|
||||||
import { useItemActions } from "./item-actions";
|
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 {
|
interface WidgetContextMenuProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
|
definition: WidgetDefinition;
|
||||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
|
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||||
const { data: session } = useSession();
|
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 [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const tItem = useScopedI18n("item");
|
const tItem = useScopedI18n("item");
|
||||||
const tMenu = useScopedI18n("item.menu.label");
|
const tMenu = useScopedI18n("item.menu.label");
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const settings = useSettings();
|
const hasSupportedIntegrations =
|
||||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||||
const { openModal } = useModalAction(WidgetEditModal);
|
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||||
useItemActions();
|
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 { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
|
||||||
const { gridstack } = useSectionContext().refs;
|
const { gridstack } = useSectionContext().refs;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const widgetQueryKey = useMemo(
|
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||||
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
|
const isWidgetFetching =
|
||||||
[currentDefinition, item.kind],
|
useIsFetching({
|
||||||
);
|
predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
|
||||||
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
}) > 0;
|
||||||
const handleRefetch = useCallback(() => {
|
const handleRefetch = useCallback(() => {
|
||||||
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" })));
|
||||||
}, [queryClient, widgetQueryKey]);
|
}, [queryClient, widgetQueryKeys]);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
|
||||||
[item.kind, settings, item.options],
|
[definition, settings, item.options],
|
||||||
);
|
);
|
||||||
|
|
||||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||||
const toggleOptions = useMemo(() => {
|
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);
|
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
||||||
}, [currentDefinition, settings]);
|
}, [definition, settings]);
|
||||||
|
|
||||||
const widgetContextActions = useMemo(() => {
|
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 [];
|
if (typeof def.contextActions !== "function") return [];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const actions = (def.contextActions as any)({
|
const actions = (def.contextActions as any)({
|
||||||
@@ -94,7 +147,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
widgetStateRef,
|
widgetStateRef,
|
||||||
});
|
});
|
||||||
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
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(
|
const persistBoard = useCallback(
|
||||||
(updatedItems: typeof board.items) => {
|
(updatedItems: typeof board.items) => {
|
||||||
@@ -107,6 +160,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
openModal(
|
openModal(
|
||||||
{
|
{
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
|
definition,
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: item.advancedOptions,
|
advancedOptions: item.advancedOptions,
|
||||||
options: item.options,
|
options: item.options,
|
||||||
@@ -133,10 +187,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
},
|
},
|
||||||
integrationData: (integrationData ?? []).filter(
|
integrationData: (integrationData ?? []).filter(
|
||||||
(integration) =>
|
(integration) =>
|
||||||
"supportedIntegrations" in currentDefinition &&
|
"supportedIntegrations" in definition &&
|
||||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||||
),
|
),
|
||||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
integrationSupport: "supportedIntegrations" in definition,
|
||||||
settings,
|
settings,
|
||||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||||
},
|
},
|
||||||
@@ -150,7 +204,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
updateItemAdvancedOptions,
|
updateItemAdvancedOptions,
|
||||||
updateItemIntegrations,
|
updateItemIntegrations,
|
||||||
integrationData,
|
integrationData,
|
||||||
currentDefinition,
|
definition,
|
||||||
settings,
|
settings,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
persistBoard,
|
persistBoard,
|
||||||
@@ -168,135 +222,133 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
[options, item.id, updateItemOptions, persistBoard, board],
|
[options, item.id, updateItemOptions, persistBoard, board],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) return <>{children}</>;
|
|
||||||
if (!isRightClickEnabled) return <>{children}</>;
|
|
||||||
|
|
||||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
|
<>
|
||||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
{toggleOptions.length > 0 && (
|
||||||
<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 || 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
|
<Menu.Item
|
||||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
closeMenuOnClick
|
||||||
onClick={handleRefetch}
|
leftSection={<IconLayoutKanban size={16} />}
|
||||||
disabled={isWidgetFetching}
|
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">
|
{tItem("action.moveResize")}
|
||||||
{tMenu("refresh")}
|
|
||||||
<WidgetQueryStatus
|
|
||||||
queryClient={queryClient}
|
|
||||||
queryKey={widgetQueryKey}
|
|
||||||
isFetching={isWidgetFetching}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
closeMenuOnClick
|
closeMenuOnClick
|
||||||
leftSection={<IconSettings size={16} />}
|
leftSection={<IconCopy size={16} />}
|
||||||
onClick={openEditModal}
|
onClick={() => duplicateItem({ itemId: item.id })}
|
||||||
disabled={isPending}
|
|
||||||
>
|
>
|
||||||
{tMenu("settings")}
|
{tItem("action.duplicate")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isEditMode && (
|
<>
|
||||||
<>
|
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||||
<Menu.Divider />
|
<Menu.Item
|
||||||
<Menu.Item
|
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||||
closeMenuOnClick
|
onClick={handleRefetch}
|
||||||
color="red"
|
disabled={isWidgetFetching}
|
||||||
leftSection={<IconTrash size={16} />}
|
>
|
||||||
onClick={() => {
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
openConfirmModal({
|
{tMenu("refresh")}
|
||||||
title: tItem("remove.title"),
|
<WidgetQueryStatus
|
||||||
children: tItem("remove.message"),
|
queryClient={queryClient}
|
||||||
onConfirm: () => removeItem({ itemId: item.id }),
|
queryKeys={widgetQueryKeys}
|
||||||
});
|
isFetching={isWidgetFetching}
|
||||||
}}
|
t={t}
|
||||||
>
|
/>
|
||||||
{tItem("action.remove")}
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
<Menu.Item
|
||||||
)}
|
closeMenuOnClick
|
||||||
</Menu.Dropdown>
|
leftSection={<IconSettings size={16} />}
|
||||||
</Menu>
|
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 {
|
interface WidgetQueryStatusProps {
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
queryKey: QueryKey;
|
queryKeys: QueryKey[];
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
t: TranslationFunction;
|
t: TranslationFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
|
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
|
||||||
const queries = queryClient.getQueryCache().findAll({ queryKey });
|
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))];
|
||||||
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
||||||
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
||||||
const ageLabel = useTimeAgo(new Date(latest));
|
const ageLabel = useTimeAgo(new Date(latest));
|
||||||
@@ -334,7 +386,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
|
|||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||||
<Text size="xs" c="red.6">
|
<Text size="xs" c="red.6">
|
||||||
{t("item.menu.status.error")} · {ageLabel}
|
{t("item.menu.status.error")}
|
||||||
|
{latest > 0 && ` · ${ageLabel}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -345,7 +398,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
|
|||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{t("item.menu.status.success")} · {ageLabel}
|
{t("item.menu.status.success")}
|
||||||
|
{latest > 0 && ` · ${ageLabel}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</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";
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, use } from "react";
|
||||||
import { Avatar, Text, Tooltip } from "@mantine/core";
|
import { Avatar, Text, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { getIconUrl } from "@homarr/definitions";
|
import { getIconUrl } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
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 type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import classes from "./widget-hover-overlay.module.css";
|
import classes from "./widget-hover-overlay.module.css";
|
||||||
@@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps {
|
|||||||
|
|
||||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||||
const [isEditMode] = useEditMode();
|
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 t = useI18n();
|
||||||
const { definition } = widgetImports[item.kind];
|
const definition = use(loadWidgetDefinition(item.kind));
|
||||||
const WidgetIcon = definition.icon;
|
const WidgetIcon = definition.icon;
|
||||||
|
|
||||||
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
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),
|
item.integrationIds.includes(integration.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isEditMode) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.wrapper}>
|
<div className={classes.wrapper}>
|
||||||
<div className={classes.panel}>
|
<div className={classes.panel}>
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||||
|
|
||||||
describe("removePersistedWidgetQueries", () => {
|
describe("removeWidgetDataQueries", () => {
|
||||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
test("removes only the failed widget prefix and allows fresh data to be fetched", async () => {
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
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: {} }];
|
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||||
|
|
||||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||||
|
queryClient.setQueryData(sameWidgetQueryKey, "related");
|
||||||
|
queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget");
|
||||||
|
queryClient.setQueryData(appQueryKey, "app");
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]);
|
||||||
|
|
||||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
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");
|
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||||
await expect(
|
await expect(
|
||||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
||||||
).resolves.toBe("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 queryClient = new QueryClient();
|
||||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
||||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||||
@@ -31,14 +40,14 @@ describe("removePersistedWidgetQueries", () => {
|
|||||||
queryClient.setQueryData(appQueryKey, "stale");
|
queryClient.setQueryData(appQueryKey, "stale");
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]);
|
||||||
|
|
||||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
expect(queryClient.getQueryData(appQueryKey)).toBe("stale");
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
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 queryClient = new QueryClient();
|
||||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
||||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||||
@@ -46,7 +55,7 @@ describe("removePersistedWidgetQueries", () => {
|
|||||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]);
|
||||||
|
|
||||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
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 removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
|
||||||
|
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
|
||||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
|
||||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
|
||||||
};
|
};
|
||||||
@@ -105,12 +105,14 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
|||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const openAllInNewTabs = useCallback(async () => {
|
const openAllInNewTabs = useCallback(async () => {
|
||||||
const currentLayoutId = getCurrentLayout(board);
|
const currentLayoutId = getCurrentLayout(board);
|
||||||
const appIds = filterByItemKind(
|
const appIds = (
|
||||||
board.items.filter(
|
await filterByItemKind(
|
||||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
board.items.filter(
|
||||||
),
|
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||||
settings,
|
),
|
||||||
"app",
|
settings,
|
||||||
|
"app",
|
||||||
|
)
|
||||||
).map((item) => {
|
).map((item) => {
|
||||||
return item.options.appId;
|
return item.options.appId;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
import type { WidgetComponentProps } from "@homarr/widgets/definition";
|
||||||
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
export const filterByItemKind = <TKind extends WidgetKind>(
|
export const filterByItemKind = async <TKind extends WidgetKind>(
|
||||||
items: Item[],
|
items: Item[],
|
||||||
settings: SettingsContextProps,
|
settings: SettingsContextProps,
|
||||||
kind: TKind,
|
kind: TKind,
|
||||||
) => {
|
) => {
|
||||||
|
const definition = await loadWidgetDefinition(kind);
|
||||||
|
|
||||||
return items
|
return items
|
||||||
.filter((item) => item.kind === kind)
|
.filter((item) => item.kind === kind)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
options: reduceWidgetOptionsWithDefaultValues(
|
options: reduceWidgetOptionsWithDefinition(
|
||||||
kind,
|
definition,
|
||||||
settings,
|
settings,
|
||||||
item.options,
|
item.options,
|
||||||
) as WidgetComponentProps<TKind>["options"],
|
) as WidgetComponentProps<TKind>["options"],
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { GridItemHTMLElement } from "@homarr/gridstack";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
|
|
||||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { BoardItemContent } from "../items/item-content";
|
import { BoardItemContent } from "../items/item-content";
|
||||||
@@ -15,7 +17,11 @@ import { useSectionItems } from "./use-section-items";
|
|||||||
|
|
||||||
export const SectionContent = () => {
|
export const SectionContent = () => {
|
||||||
const { innerSections, items, refs } = useSectionContext();
|
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
|
* 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 type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import {
|
import {
|
||||||
featuredIntegrations,
|
featuredIntegrations,
|
||||||
@@ -6,30 +5,8 @@ import {
|
|||||||
hiddenFromOnboarding,
|
hiddenFromOnboarding,
|
||||||
integrationDefs,
|
integrationDefs,
|
||||||
integrationKinds,
|
integrationKinds,
|
||||||
|
getWidgetKindsForIntegration,
|
||||||
} from "@homarr/definitions";
|
} 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> = {
|
export const categoryTranslationKeys: Record<string, string> = {
|
||||||
dnsHole: "integration.category.dnsHole",
|
dnsHole: "integration.category.dnsHole",
|
||||||
@@ -70,11 +47,11 @@ export interface IntegrationGridItem {
|
|||||||
|
|
||||||
export const buildSortedIntegrations = (
|
export const buildSortedIntegrations = (
|
||||||
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
|
||||||
): IntegrationGridItem[] =>
|
): IntegrationGridItem[] => {
|
||||||
integrationKinds
|
return integrationKinds
|
||||||
.filter((kind) => {
|
.filter((kind) => {
|
||||||
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
|
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;
|
if (!options.enableMockIntegration && kind === "mock") return false;
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
@@ -82,9 +59,9 @@ export const buildSortedIntegrations = (
|
|||||||
kind,
|
kind,
|
||||||
name: getIntegrationName(kind),
|
name: getIntegrationName(kind),
|
||||||
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
|
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 leftIdx = featuredIntegrations.indexOf(left.kind);
|
||||||
const rightIdx = featuredIntegrations.indexOf(right.kind);
|
const rightIdx = featuredIntegrations.indexOf(right.kind);
|
||||||
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
|
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
|
||||||
@@ -92,6 +69,7 @@ export const buildSortedIntegrations = (
|
|||||||
if (rightIdx !== -1) return 1;
|
if (rightIdx !== -1) return 1;
|
||||||
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
|
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
|
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
|
||||||
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
|
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
import { Spotlight } from "@homarr/spotlight";
|
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { ClientBurger } from "./header/burger";
|
import { ClientBurger } from "./header/burger";
|
||||||
@@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
|||||||
import { TourTarget } from "./header/tour-target";
|
import { TourTarget } from "./header/tour-target";
|
||||||
import { UserButton } from "./header/user";
|
import { UserButton } from "./header/user";
|
||||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||||
|
import { LazySpotlight } from "./header/lazy-spotlight";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logo?: ReactNode;
|
logo?: ReactNode;
|
||||||
@@ -37,7 +37,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
|||||||
</TourTarget>
|
</TourTarget>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Spotlight />
|
<LazySpotlight />
|
||||||
</AppShellHeader>
|
</AppShellHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -14,6 +14,9 @@ type HeaderButtonProps = (
|
|||||||
}
|
}
|
||||||
) & {
|
) & {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onPointerDown?: () => void;
|
||||||
|
onPointerEnter?: () => void;
|
||||||
} & Partial<ActionIconProps>;
|
} & Partial<ActionIconProps>;
|
||||||
|
|
||||||
const headerButtonActionIconProps: 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 { TextInput, UnstyledButton } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { openSpotlight } from "@homarr/spotlight";
|
import { openSpotlight } from "@homarr/spotlight/open";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { HeaderButton } from "./button";
|
import { HeaderButton } from "./button";
|
||||||
import classes from "./search.module.css";
|
import classes from "./search.module.css";
|
||||||
|
import { preloadSpotlight } from "./lazy-spotlight";
|
||||||
|
|
||||||
export const DesktopSearchInput = () => {
|
export const DesktopSearchInput = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
data-homarr-dev-benchmark-interaction="search"
|
||||||
component={UnstyledButton}
|
component={UnstyledButton}
|
||||||
className={classes.desktopSearch}
|
className={classes.desktopSearch}
|
||||||
w={400}
|
w={400}
|
||||||
size="sm"
|
size="sm"
|
||||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||||
onClick={openSpotlight}
|
onClick={openSpotlight}
|
||||||
|
onFocus={preloadSpotlight}
|
||||||
|
onPointerDown={preloadSpotlight}
|
||||||
|
onPointerEnter={preloadSpotlight}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
>
|
>
|
||||||
{`${t("search.placeholder")}...`}
|
{`${t("search.placeholder")}...`}
|
||||||
@@ -29,7 +34,14 @@ export const DesktopSearchInput = () => {
|
|||||||
|
|
||||||
export const MobileSearchButton = () => {
|
export const MobileSearchButton = () => {
|
||||||
return (
|
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} />
|
<IconSearch size={20} stroke={1.5} />
|
||||||
</HeaderButton>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
import { createContext, Suspense, use, useContext } from "react";
|
||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
|
const TourTargetsEnabledContext = createContext(false);
|
||||||
<OnboardingTour.Target id={id}>
|
let tourLibraryPromise: Promise<typeof import("@gfazioli/mantine-onboarding-tour")> | undefined;
|
||||||
<Box data-tour-target={id}>{children}</Box>
|
|
||||||
</OnboardingTour.Target>
|
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";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { Suspense, use } from "react";
|
|
||||||
import { Indicator, Menu, Text } from "@mantine/core";
|
import { Indicator, Menu, Text } from "@mantine/core";
|
||||||
import { IconBellRinging } from "@tabler/icons-react";
|
import { IconBellRinging } from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -9,67 +8,28 @@ import type { RouterOutputs } from "@homarr/api";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface UpdateIndicatorProps extends PropsWithChildren {
|
interface UpdateIndicatorProps extends PropsWithChildren {
|
||||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
|
export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => {
|
||||||
if (disabled || availableUpdatesPromise === undefined) {
|
if (disabled || availableUpdates === undefined) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={children}>
|
<Indicator disabled={availableUpdates.length === 0} size={15} processing withBorder>
|
||||||
<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
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Indicator>
|
</Indicator>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AvailableUpdatesMenuItemProps {
|
interface AvailableUpdatesMenuItemProps {
|
||||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => {
|
export const AvailableUpdatesMenuItem = ({ availableUpdates }: 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) => {
|
|
||||||
const t = useScopedI18n("common.userAvatar.menu");
|
const t = useScopedI18n("common.userAvatar.menu");
|
||||||
const availableUpdates = use(availableUpdatesPromise);
|
|
||||||
if (availableUpdates === undefined || availableUpdates.length === 0) {
|
if (availableUpdates === undefined || availableUpdates.length === 0) {
|
||||||
return null;
|
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 { auth } from "@homarr/auth/next";
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
|
|
||||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
import { UserButtonClient } from "./user-client";
|
||||||
import { UpdateIndicator } from "./update";
|
|
||||||
|
|
||||||
export const UserButton = async () => {
|
export const UserButton = async () => {
|
||||||
const session = await auth();
|
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 isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
|
||||||
return (
|
return (
|
||||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
|
||||||
<UnstyledButton>
|
|
||||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
|
||||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
|
||||||
<CurrentUserAvatar size="md" />
|
|
||||||
</UpdateIndicator>
|
|
||||||
</Suspense>
|
|
||||||
</UnstyledButton>
|
|
||||||
</UserAvatarMenu>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
|
||||||
import { NavLink } from "@mantine/core";
|
import { NavLink } from "@mantine/core";
|
||||||
|
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { TourTarget } from "./header/tour-target";
|
||||||
|
|
||||||
export const CommonNavLink = (props: ClientNavigationLink) =>
|
export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||||
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
||||||
|
|
||||||
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
|
||||||
if (!id) return <>{children}</>;
|
id ? <TourTarget id={id}>{children}</TourTarget> : children;
|
||||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
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)}
|
active={isClient && pathMatches(pathname, props.href)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return <TourTarget id={tourId}>{link}</TourTarget>;
|
return withOptionalTourTarget(tourId, link);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||||
@@ -56,7 +55,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
|||||||
))}
|
))}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
return withOptionalTourTarget(props["data-onboarding-tour-id"], nav);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CommonNavigationLinkProps {
|
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";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -10,6 +10,7 @@ import { createDocumentationLink } from "@homarr/definitions";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
|
||||||
import { TourShell } from "./tour-shell";
|
import { TourShell } from "./tour-shell";
|
||||||
import { TourStepContent } from "./tour-step-content";
|
import { TourStepContent } from "./tour-step-content";
|
||||||
|
|
||||||
@@ -17,21 +18,11 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const t = useScopedI18n("onboardingTour.board");
|
const t = useScopedI18n("onboardingTour.board");
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
const utils = clientApi.useUtils();
|
const [started, setStarted] = useState(true);
|
||||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
|
||||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
void utils.user.getTourStatus.invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
|
|
||||||
|
|
||||||
const handleEnd = () => {
|
const handleEnd = () => {
|
||||||
utils.user.getTourStatus.setData(undefined, {
|
setStarted(false);
|
||||||
completedManageTour: tourStatus?.completedManageTour ?? false,
|
|
||||||
completedBoardTour: true,
|
|
||||||
});
|
|
||||||
completeTour({ tour: "board" });
|
completeTour({ tour: "board" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,8 +92,10 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}, [hasChangeAccess, t]);
|
}, [hasChangeAccess, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
<TourTargetsProvider enabled={started}>
|
||||||
{children}
|
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||||
</TourShell>
|
{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";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
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 { usePathname, useRouter } from "next/navigation";
|
||||||
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
@@ -17,6 +17,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { TourTargetsProvider } from "~/components/layout/header/tour-target";
|
||||||
import { TourShell } from "./tour-shell";
|
import { TourShell } from "./tour-shell";
|
||||||
import { TourStepContent } from "./tour-step-content";
|
import { TourStepContent } from "./tour-step-content";
|
||||||
|
|
||||||
@@ -46,13 +47,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
|
|||||||
|
|
||||||
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
|
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
|
||||||
const t = useScopedI18n("onboardingTour.manage");
|
const t = useScopedI18n("onboardingTour.manage");
|
||||||
const utils = clientApi.useUtils();
|
const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
|
||||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
|
||||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
void utils.user.getTourStatus.invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -62,14 +57,13 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
|||||||
|
|
||||||
const [tourActive, setTourActive] = useState(false);
|
const [tourActive, setTourActive] = useState(false);
|
||||||
const [tourDismissed, setTourDismissed] = useState(false);
|
const [tourDismissed, setTourDismissed] = useState(false);
|
||||||
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tourDismissed) return;
|
if (tourDismissed) return;
|
||||||
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
|
if (isManageHome && !isMobile) {
|
||||||
setTourActive(true);
|
setTourActive(true);
|
||||||
}
|
}
|
||||||
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
|
}, [isManageHome, isMobile, tourDismissed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isManageSection) {
|
if (!isManageSection) {
|
||||||
@@ -77,25 +71,12 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
|||||||
}
|
}
|
||||||
}, [isManageSection]);
|
}, [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(() => {
|
const handleEnd = useCallback(() => {
|
||||||
setTourDismissed(true);
|
setTourDismissed(true);
|
||||||
setTourActive(false);
|
setTourActive(false);
|
||||||
utils.user.getTourStatus.setData(undefined, {
|
|
||||||
completedManageTour: true,
|
|
||||||
completedBoardTour: tourStatus?.completedBoardTour ?? false,
|
|
||||||
});
|
|
||||||
completeTour({ tour: "manage" });
|
completeTour({ tour: "manage" });
|
||||||
router.push("/manage");
|
router.push("/manage");
|
||||||
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
|
}, [completeTour, router]);
|
||||||
|
|
||||||
const steps = useMemo(() => {
|
const steps = useMemo(() => {
|
||||||
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
|
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
|
||||||
@@ -207,14 +188,16 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
|||||||
}, [isAdmin, t]);
|
}, [isAdmin, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourShell
|
<TourTargetsProvider enabled={tourActive}>
|
||||||
steps={steps}
|
<TourShell
|
||||||
started={tourActive}
|
steps={steps}
|
||||||
onEnd={handleEnd}
|
started={tourActive}
|
||||||
stepRoutes={stepRoutes}
|
onEnd={handleEnd}
|
||||||
position={{ base: "bottom", sm: "right" }}
|
stepRoutes={stepRoutes}
|
||||||
>
|
position={{ base: "bottom", sm: "right" }}
|
||||||
{children}
|
>
|
||||||
</TourShell>
|
{children}
|
||||||
|
</TourShell>
|
||||||
|
</TourTargetsProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,24 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { Menu } from "@mantine/core";
|
||||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
import { IconBrandDocker, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
|
||||||
import {
|
|
||||||
IconBrandDocker,
|
|
||||||
IconCheck,
|
|
||||||
IconHome,
|
|
||||||
IconLogin,
|
|
||||||
IconLogout,
|
|
||||||
IconSettings,
|
|
||||||
IconTool,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { signOut, useSession } from "@homarr/auth/client";
|
import { signOut, useSession } from "@homarr/auth/client";
|
||||||
import { hotkeys } from "@homarr/definitions";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { createModal, useModalAction } from "@homarr/modals";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
@@ -30,42 +19,38 @@ import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
|||||||
|
|
||||||
interface UserAvatarMenuProps {
|
interface UserAvatarMenuProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||||
isDockerEnabled?: boolean;
|
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 t = useScopedI18n("common.userAvatar.menu");
|
||||||
const { toggleColorScheme } = useMantineColorScheme();
|
|
||||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
|
||||||
|
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { logoutUrl } = useAuthContext();
|
const { logoutUrl } = useAuthContext();
|
||||||
const { openModal } = useModalAction(LogoutModal);
|
|
||||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||||
|
|
||||||
const handleSignout = useCallback(async () => {
|
const handleSignout = useCallback(async () => {
|
||||||
|
const redirectUrl = logoutUrl ?? "/auth/login";
|
||||||
await signOut({
|
await signOut({
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
openModal({
|
window.location.assign(redirectUrl);
|
||||||
onTimeout: () => {
|
}, [logoutUrl]);
|
||||||
if (logoutUrl) {
|
|
||||||
window.location.assign(logoutUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push("/auth/login");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [logoutUrl, openModal, router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
// 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>
|
<Menu.Dropdown>
|
||||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||||
{t("homeBoard")}
|
{t("homeBoard")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -113,35 +98,3 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
|||||||
</Menu>
|
</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() {
|
export async function register() {
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||||
await import("@homarr/tasks");
|
if (process.env.NODE_ENV !== "production") return;
|
||||||
await import("@homarr/websocket");
|
|
||||||
}
|
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 type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { getDefaultLocaleForProxyAsync, getOnboardingStepForProxyAsync } from "@homarr/db/proxy-reader";
|
||||||
import { localeCookieKey } from "@homarr/definitions";
|
import { localeCookieKey } from "@homarr/definitions/cookie";
|
||||||
import type { SupportedLanguage } from "@homarr/translation";
|
import type { SupportedLanguage } from "@homarr/translation/languages";
|
||||||
import { supportedLanguages } from "@homarr/translation";
|
import { supportedLanguages } from "@homarr/translation/languages";
|
||||||
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
||||||
|
|
||||||
let isOnboardingFinished = false;
|
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) {
|
export async function proxy(request: NextRequest) {
|
||||||
// Redirect to onboarding if it's not finished yet
|
// Redirect to onboarding if it's not finished yet
|
||||||
const pathname = request.nextUrl.pathname;
|
const pathname = request.nextUrl.pathname;
|
||||||
|
|
||||||
if (!isOnboardingFinished && !pathname.endsWith("/init")) {
|
if (!isOnboardingFinished && !pathname.endsWith("/init")) {
|
||||||
const currentOnboardingStep = await api.onboard.currentStep();
|
const currentOnboardingStep = await getOnboardingStepDedupedAsync();
|
||||||
if (currentOnboardingStep.current !== "finish") {
|
if (currentOnboardingStep !== "finish") {
|
||||||
return NextResponse.redirect(new URL("/init", request.url));
|
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;
|
const currentLocale = request.cookies.get(localeCookieKey)?.value;
|
||||||
let defaultLocale: SupportedLanguage = "en";
|
let defaultLocale: SupportedLanguage = "en";
|
||||||
if (!currentLocale || !supportedLanguages.includes(currentLocale as SupportedLanguage)) {
|
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
|
// We don't want to fallback to accept-language header so we clear it
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
import { db } from "@homarr/db";
|
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
|
||||||
import type { ColorScheme } from "@homarr/definitions";
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||||
|
|
||||||
@@ -13,6 +12,6 @@ export const getCurrentColorSchemeAsync = cache(async () => {
|
|||||||
return cookieValue as ColorScheme;
|
return cookieValue as ColorScheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appearanceSettings = await getServerSettingByKeyAsync(db, "appearance");
|
const serverSettings = await getRscServerSettingsAsync();
|
||||||
return appearanceSettings.defaultColorScheme;
|
return serverSettings.appearance.defaultColorScheme;
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,7 @@ import { jobGroup } from "@homarr/cron-jobs";
|
|||||||
|
|
||||||
import { onStartAsync } from "./on-start";
|
import { onStartAsync } from "./on-start";
|
||||||
|
|
||||||
void (async () => {
|
export const startupPromise = (async () => {
|
||||||
await onStartAsync();
|
await onStartAsync();
|
||||||
await jobGroup.initializeAsync();
|
await jobGroup.initializeAsync();
|
||||||
await jobGroup.startAllAsync();
|
await jobGroup.startAllAsync();
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { cleanupSessionsAsync } from "./session-cleanup";
|
import { cleanupSessionsAsync } from "./session-cleanup";
|
||||||
import { warmUpdateCheckerAsync } from "./warm-update-checker";
|
|
||||||
|
|
||||||
export async function onStartAsync() {
|
export async function onStartAsync() {
|
||||||
await cleanupSessionsAsync();
|
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({
|
const wss = new WebSocketServer({
|
||||||
port: 3001,
|
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({
|
const handler = applyWSSHandler({
|
||||||
wss,
|
wss,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
@@ -57,7 +76,6 @@ wss.on("connection", (websocket, incomingMessage) => {
|
|||||||
logger.info(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`);
|
logger.info(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
logger.info("✅ WebSocket Server listening on ws://localhost:3001");
|
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
logger.info("SIGTERM");
|
logger.info("SIGTERM");
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ describe("Custom JSX custom widgets", () => {
|
|||||||
await page.goto(`${baseUrl}/auth/login`);
|
await page.goto(`${baseUrl}/auth/login`);
|
||||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||||
await page.locator("#password").fill(adminCredentials.password);
|
await page.locator("#password").fill(adminCredentials.password);
|
||||||
|
const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
|
||||||
await page.locator("css=button[type='submit']").click();
|
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.goto(`${baseUrl}/manage/custom-widgets/new`);
|
||||||
await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 });
|
await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 });
|
||||||
|
|||||||
+26
-18
@@ -8,13 +8,17 @@ describe("Health checks", () => {
|
|||||||
// Arrange
|
// Arrange
|
||||||
const homarrContainer = await createHomarrContainer().start();
|
const homarrContainer = await createHomarrContainer().start();
|
||||||
|
|
||||||
// Act
|
try {
|
||||||
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
|
// Act
|
||||||
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
|
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
|
// Assert
|
||||||
expect(readyResponse.status).toBe(200);
|
expect(readyResponse.status).toBe(200);
|
||||||
expect(liveResponse.status).toBe(200);
|
expect(liveResponse.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await homarrContainer.stop();
|
||||||
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test("ready and live should return 200 OK with external redis", async () => {
|
test("ready and live should return 200 OK with external redis", async () => {
|
||||||
@@ -29,18 +33,22 @@ describe("Health checks", () => {
|
|||||||
},
|
},
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
// Act
|
try {
|
||||||
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
|
// Act
|
||||||
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
|
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
|
// Assert
|
||||||
expect(
|
expect(
|
||||||
readyResponse.status,
|
readyResponse.status,
|
||||||
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
|
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
|
||||||
).toBe(200);
|
).toBe(200);
|
||||||
expect(
|
expect(
|
||||||
liveResponse.status,
|
liveResponse.status,
|
||||||
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
|
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
|
||||||
).toBe(200);
|
).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([homarrContainer.stop(), redisContainer.stop()]);
|
||||||
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
+8
-4
@@ -7,10 +7,14 @@ describe("Home", () => {
|
|||||||
// Arrange
|
// Arrange
|
||||||
const homarrContainer = await createHomarrContainer().start();
|
const homarrContainer = await createHomarrContainer().start();
|
||||||
|
|
||||||
// Act
|
try {
|
||||||
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
|
// Act
|
||||||
|
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(homeResponse.status).toBe(200);
|
expect(homeResponse.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await homarrContainer.stop();
|
||||||
|
}
|
||||||
}, 60_000);
|
}, 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 context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
const onboardingActions = new OnboardingActions(page, db);
|
try {
|
||||||
await onboardingActions.skipOnboardingAsync({
|
const onboardingActions = new OnboardingActions(page, db);
|
||||||
group: defaultCredentials.group,
|
await onboardingActions.skipOnboardingAsync({
|
||||||
});
|
group: defaultCredentials.group,
|
||||||
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||||
await page.getByLabel("Username").fill(defaultCredentials.username);
|
await page.goto(`${baseUrl}/auth/login`);
|
||||||
await page.locator("#password").fill(defaultCredentials.password);
|
await page.getByLabel("Username").fill(defaultCredentials.username);
|
||||||
await page.locator("css=button[type='submit']").click();
|
await page.locator("#password").fill(defaultCredentials.password);
|
||||||
|
await page.locator("css=button[type='submit']").click();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
await page.waitForURL(baseUrl, { timeout: 60_000 });
|
||||||
const users = await db.query.users.findMany({
|
const users = await db.query.users.findMany({
|
||||||
with: {
|
with: {
|
||||||
groups: {
|
groups: {
|
||||||
with: {
|
with: {
|
||||||
group: true,
|
group: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
expect(users).toHaveLength(1);
|
||||||
expect(users).toHaveLength(1);
|
const user = users[0];
|
||||||
const user = users[0]!;
|
if (!user) {
|
||||||
expect(user).toEqual(
|
throw new Error("Expected LDAP login to create one user");
|
||||||
expect.objectContaining({
|
}
|
||||||
name: defaultCredentials.username,
|
expect(user).toEqual(
|
||||||
email: defaultCredentials.email,
|
expect.objectContaining({
|
||||||
provider: "ldap",
|
name: defaultCredentials.username,
|
||||||
}),
|
email: defaultCredentials.email,
|
||||||
);
|
provider: "ldap",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const groups = user.groups.map((g) => g.group.name);
|
const groups = user.groups.map((g) => g.group.name);
|
||||||
expect(groups).toContain(defaultCredentials.group);
|
expect(groups).toContain(defaultCredentials.group);
|
||||||
|
} finally {
|
||||||
// Cleanup
|
await Promise.all([browser.close(), homarrContainer.stop(), lldapContainer.stop()]);
|
||||||
await browser.close();
|
}
|
||||||
await homarrContainer.stop();
|
|
||||||
await lldapContainer.stop();
|
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+34
-34
@@ -22,25 +22,25 @@ describe("Onboarding", () => {
|
|||||||
const actions = new OnboardingActions(page, db);
|
const actions = new OnboardingActions(page, db);
|
||||||
const assertions = new OnboardingAssertions(page, db);
|
const assertions = new OnboardingAssertions(page, db);
|
||||||
|
|
||||||
// Act
|
try {
|
||||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
// Act
|
||||||
await actions.startOnboardingAsync("scratch");
|
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||||
await actions.processUserStepAsync({
|
await actions.startOnboardingAsync("scratch");
|
||||||
username: "admin",
|
await actions.processUserStepAsync({
|
||||||
password: "Comp(exP4sswOrd",
|
username: "admin",
|
||||||
confirmPassword: "Comp(exP4sswOrd",
|
password: "Comp(exP4sswOrd",
|
||||||
});
|
confirmPassword: "Comp(exP4sswOrd",
|
||||||
await actions.processSettingsStepAsync();
|
});
|
||||||
await actions.processIntegrationsStepAsync();
|
await actions.processSettingsStepAsync();
|
||||||
|
await actions.processIntegrationsStepAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await assertions.assertFinishStepVisibleAsync();
|
await assertions.assertFinishStepVisibleAsync();
|
||||||
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
|
||||||
await assertions.assertDbOnboardingStepAsync("finish");
|
await assertions.assertDbOnboardingStepAsync("finish");
|
||||||
|
} finally {
|
||||||
// Cleanup
|
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||||
await browser.close();
|
}
|
||||||
await homarrContainer.stop();
|
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test("External provider onboarding setup should be successful", async () => {
|
test("External provider onboarding setup should be successful", async () => {
|
||||||
@@ -66,22 +66,22 @@ describe("Onboarding", () => {
|
|||||||
const actions = new OnboardingActions(page, db);
|
const actions = new OnboardingActions(page, db);
|
||||||
const assertions = new OnboardingAssertions(page, db);
|
const assertions = new OnboardingAssertions(page, db);
|
||||||
|
|
||||||
// Act
|
try {
|
||||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
// Act
|
||||||
await actions.startOnboardingAsync("scratch");
|
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||||
await actions.processExternalGroupStepAsync({
|
await actions.startOnboardingAsync("scratch");
|
||||||
name: externalGroupName,
|
await actions.processExternalGroupStepAsync({
|
||||||
});
|
name: externalGroupName,
|
||||||
await actions.processSettingsStepAsync();
|
});
|
||||||
await actions.processIntegrationsStepAsync();
|
await actions.processSettingsStepAsync();
|
||||||
|
await actions.processIntegrationsStepAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
await assertions.assertFinishStepVisibleAsync();
|
await assertions.assertFinishStepVisibleAsync();
|
||||||
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||||
await assertions.assertDbOnboardingStepAsync("finish");
|
await assertions.assertDbOnboardingStepAsync("finish");
|
||||||
|
} finally {
|
||||||
// Cleanup
|
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||||
await browser.close();
|
}
|
||||||
await homarrContainer.stop();
|
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async assertFinishStepVisibleAsync() {
|
public async assertFinishStepVisibleAsync() {
|
||||||
await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
|
await this.page.waitForSelector("text=completed the setup", { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-1
@@ -4,6 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"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": "git clean -xdf node_modules",
|
||||||
"clean:workspaces": "turbo clean",
|
"clean:workspaces": "turbo clean",
|
||||||
"ci:act:all": "bash scripts/ci/act.sh all",
|
"ci:act:all": "bash scripts/ci/act.sh all",
|
||||||
@@ -21,6 +26,7 @@
|
|||||||
"db:push": "pnpm -F db push:sqlite",
|
"db:push": "pnpm -F db push:sqlite",
|
||||||
"db:studio": "pnpm -F db studio",
|
"db:studio": "pnpm -F db studio",
|
||||||
"dev": "pnpm -F nextjs dev",
|
"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": "go -C tools/homarr-dev run .",
|
||||||
"dev:cli:install": "go -C tools/homarr-dev install .",
|
"dev:cli:install": "go -C tools/homarr-dev install .",
|
||||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||||
@@ -34,7 +40,7 @@
|
|||||||
"release": "semantic-release",
|
"release": "semantic-release",
|
||||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.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": "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: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",
|
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||||
@@ -45,6 +51,7 @@
|
|||||||
"jsonpath-plus": "catalog:"
|
"jsonpath-plus": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/parser": "catalog:",
|
||||||
"@playwright/test": "catalog:",
|
"@playwright/test": "catalog:",
|
||||||
"@semantic-release/changelog": "catalog:",
|
"@semantic-release/changelog": "catalog:",
|
||||||
"@semantic-release/commit-analyzer": "catalog:",
|
"@semantic-release/commit-analyzer": "catalog:",
|
||||||
@@ -65,6 +72,7 @@
|
|||||||
"oxlint": "catalog:",
|
"oxlint": "catalog:",
|
||||||
"semantic-release": "catalog:",
|
"semantic-release": "catalog:",
|
||||||
"testcontainers": "catalog:",
|
"testcontainers": "catalog:",
|
||||||
|
"tsx": "catalog:",
|
||||||
"turbo": "catalog:",
|
"turbo": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client.ts",
|
||||||
|
"./board-server": "./src/board-server.ts",
|
||||||
"./query-cache": "./src/query-cache.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",
|
"./server": "./src/server.ts",
|
||||||
"./websocket": "./src/websocket.ts",
|
"./websocket": "./src/websocket.ts",
|
||||||
"./shared": "./src/shared.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";
|
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 queryCacheDefaultRefetchIntervalMs = 30_000;
|
||||||
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
|
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
||||||
export const queryCacheMaxValueBytes = 1024 * 1024;
|
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||||
export const queryCacheStoragePrefix = "homarr-widget-query";
|
|
||||||
export const queryCacheBuster = "v2-superjson";
|
|
||||||
|
|
||||||
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) => {
|
export const isWidgetDataTrpcPath = (path: string) => {
|
||||||
activeQueryCacheBoardId = boardId;
|
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
|
||||||
|
return widgetDataQueryPaths.has(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
|
// These supporting queries can be numerous on a dashboard. Give them a
|
||||||
|
// predictable refresh policy instead of refetching all of them after every tab
|
||||||
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
// focus/reconnect event. Metadata can refresh after a bounded stale window;
|
||||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
// 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 getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||||
const first = queryKey[0];
|
const first = queryKey[0];
|
||||||
@@ -26,11 +58,9 @@ const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
|||||||
return parts.length > 0 ? parts : null;
|
return parts.length > 0 ? parts : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
|
export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
|
||||||
const path = getTrpcPathFromQueryKey(queryKey);
|
const path = getTrpcPathFromQueryKey(queryKey);
|
||||||
if (!path) return false;
|
if (!path) return false;
|
||||||
|
|
||||||
if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
|
return isWidgetDataTrpcPath(path.join("."));
|
||||||
|
|
||||||
return persistableQueryPaths.has(path.join("."));
|
|
||||||
};
|
};
|
||||||
@@ -24,7 +24,6 @@ export const appRouter = createTRPCRouter({
|
|||||||
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
|
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
|
||||||
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
|
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
|
||||||
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
|
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)),
|
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||||
|
|||||||
@@ -1757,9 +1757,12 @@ const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithW
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
||||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
const groupPermissionWhere = userId
|
||||||
where: eq(groupMembers.userId, 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({
|
const board = await db.query.boards.findFirst({
|
||||||
where,
|
where,
|
||||||
with: {
|
with: {
|
||||||
@@ -1782,8 +1785,8 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
items: {
|
items: {
|
||||||
with: {
|
with: {
|
||||||
integrations: {
|
integrations: {
|
||||||
with: {
|
columns: {
|
||||||
integration: true,
|
integrationId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layouts: true,
|
layouts: true,
|
||||||
@@ -1797,7 +1800,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
groupPermissions: {
|
groupPermissions: {
|
||||||
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
|
where: groupPermissionWhere,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1845,7 +1848,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
layoutId: layout.layoutId,
|
layoutId: layout.layoutId,
|
||||||
sectionId: layout.sectionId,
|
sectionId: layout.sectionId,
|
||||||
})),
|
})),
|
||||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
integrationIds: itemIntegrations.map((item) => item.integrationId),
|
||||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ export const throwIfActionForbiddenAsync = async (
|
|||||||
permission: BoardPermission,
|
permission: BoardPermission,
|
||||||
) => {
|
) => {
|
||||||
const { db, session } = ctx;
|
const { db, session } = ctx;
|
||||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
const groupPermissionWhere = session
|
||||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
? 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({
|
const board = await db.query.boards.findFirst({
|
||||||
where: boardWhere,
|
where: boardWhere,
|
||||||
columns: {
|
columns: {
|
||||||
@@ -34,7 +37,7 @@ export const throwIfActionForbiddenAsync = async (
|
|||||||
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
|
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
|
||||||
},
|
},
|
||||||
groupPermissions: {
|
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);
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { Session } from "@homarr/auth";
|
||||||
|
import type { Database } from "@homarr/db";
|
||||||
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { updateCheckerRouter } from "../update-checker";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getDataAsync: vi.fn(async () => ({
|
||||||
|
data: { availableUpdates: [{ tagName: "v99.0.0" }] },
|
||||||
|
timestamp: new Date(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@homarr/request-handler/update-checker", () => ({
|
||||||
|
updateCheckerRequestHandler: {
|
||||||
|
handler: () => ({ getDataAsync: mocks.getDataAsync }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createSession = (permissions: GroupPermissionKey[]): Session => ({
|
||||||
|
user: {
|
||||||
|
id: "user",
|
||||||
|
permissions,
|
||||||
|
colorScheme: "light",
|
||||||
|
},
|
||||||
|
expires: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCaller = (session: Session | null) =>
|
||||||
|
updateCheckerRouter.createCaller({
|
||||||
|
db: null as unknown as Database,
|
||||||
|
deviceType: undefined,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update checker authorization", () => {
|
||||||
|
test("allows administrators to read the cached result", async () => {
|
||||||
|
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([
|
||||||
|
{ tagName: "v99.0.0" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects authenticated users without admin permission", async () => {
|
||||||
|
await expect(createCaller(createSession([])).getAvailableUpdates()).rejects.toThrow("Permission denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects anonymous users", async () => {
|
||||||
|
await expect(createCaller(null).getAvailableUpdates()).rejects.toThrow("UNAUTHORIZED");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns an empty result when the cache reader fails", async () => {
|
||||||
|
mocks.getDataAsync.mockRejectedValueOnce(new Error("Redis unavailable"));
|
||||||
|
|
||||||
|
await expect(createCaller(createSession(["admin"])).getAvailableUpdates()).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,8 +12,8 @@ export const updateCheckerRouter = createTRPCRouter({
|
|||||||
const data = await handler.getDataAsync();
|
const data = await handler.getDataAsync();
|
||||||
return data.data.availableUpdates;
|
return data.data.availableUpdates;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(new Error("Failed to get available updates", { cause: error }));
|
logger.error(new Error("Failed to read the cached update check", { cause: error }));
|
||||||
return undefined; // We return undefined to not show the indicator in the UI
|
return []; // An empty list hides the indicator without violating TanStack Query's data contract.
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@ import SuperJSON from "superjson";
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { eq } from "@homarr/db";
|
import { eq } from "@homarr/db";
|
||||||
import { items, users } from "@homarr/db/schema";
|
import { items, users } from "@homarr/db/schema";
|
||||||
@@ -20,6 +21,8 @@ const feedsInput = z.object({
|
|||||||
|
|
||||||
export const rssFeedRouter = createTRPCRouter({
|
export const rssFeedRouter = createTRPCRouter({
|
||||||
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
|
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
|
||||||
|
if (env.NO_EXTERNAL_CONNECTION) return [];
|
||||||
|
|
||||||
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
|
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
|
||||||
? input.urls
|
? input.urls
|
||||||
: await restrictUrlsAsync(ctx.db, input.urls);
|
: await restrictUrlsAsync(ctx.db, input.urls);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { env } from "@homarr/common/env";
|
||||||
import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
@@ -11,6 +12,8 @@ const atLocationInput = z.object({
|
|||||||
|
|
||||||
export const weatherRouter = createTRPCRouter({
|
export const weatherRouter = createTRPCRouter({
|
||||||
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
|
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
|
||||||
|
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||||
|
|
||||||
const handler = weatherRequestHandler.handler(input);
|
const handler = weatherRequestHandler.handler(input);
|
||||||
return await handler.getDataAsync().then((result) => result.data);
|
return await handler.getDataAsync().then((result) => result.data);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { cache } from "react";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
|
||||||
|
import { createTRPCContext } from "./trpc";
|
||||||
|
|
||||||
|
export const createRscTrpcContext = cache(async () => {
|
||||||
|
const requestHeaders = new Headers(await headers());
|
||||||
|
requestHeaders.set("x-trpc-source", "rsc");
|
||||||
|
|
||||||
|
return createTRPCContext({
|
||||||
|
session: await auth(),
|
||||||
|
headers: requestHeaders,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
import { db } from "@homarr/db";
|
||||||
|
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||||
|
|
||||||
|
// One settings snapshot per RSC request, shared by the root layout, theme, and
|
||||||
|
// widget prefetching. React invalidates this cache between requests.
|
||||||
|
export const getRscServerSettingsAsync = cache(async () => await getServerSettingsAsync(db));
|
||||||
@@ -1,25 +1,16 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||||
|
|
||||||
import { appRouter, createCaller, createTRPCContext } from "@homarr/api";
|
import { appRouter, createCaller } from "@homarr/api";
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
|
|
||||||
|
import { createRscTrpcContext } from "./rsc-context";
|
||||||
import { makeQueryClient } from "./shared";
|
import { makeQueryClient } from "./shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||||
* handling a tRPC call from a React Server Component.
|
* handling a tRPC call from a React Server Component.
|
||||||
*/
|
*/
|
||||||
const createContext = cache(async () => {
|
const createContext = () => createRscTrpcContext();
|
||||||
const heads = new Headers(await headers());
|
|
||||||
heads.set("x-trpc-source", "rsc");
|
|
||||||
|
|
||||||
return createTRPCContext({
|
|
||||||
session: await auth(),
|
|
||||||
headers: heads,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export const api = createCaller(createContext);
|
export const api = createCaller(createContext);
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,20 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
|
||||||
import { createId } from "@homarr/common";
|
|
||||||
import type { Database } from "@homarr/db";
|
|
||||||
import { boards, users } from "@homarr/db/schema";
|
|
||||||
import { createDb } from "@homarr/db/test";
|
|
||||||
import {
|
import {
|
||||||
isPersistableWidgetQueryKey,
|
dashboardSupportingQueryPolicies,
|
||||||
|
isWidgetDataQueryKey,
|
||||||
|
isWidgetDataTrpcPath,
|
||||||
|
queryCacheDefaultRefetchIntervalMs,
|
||||||
queryCacheDefaultStaleTimeMs,
|
queryCacheDefaultStaleTimeMs,
|
||||||
queryCacheMaxValueBytes,
|
queryCacheMetadataStaleTimeMs,
|
||||||
} from "@homarr/api/query-cache";
|
} from "@homarr/api/query-cache";
|
||||||
|
|
||||||
import { queryCacheRouter } from "../router/query-cache";
|
|
||||||
|
|
||||||
const redisMock = vi.hoisted(() => {
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
values,
|
|
||||||
setQueryCacheAsync: vi.fn(
|
|
||||||
async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
|
|
||||||
if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
|
|
||||||
values.set(`${_userId}:${_boardId}`, value);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
|
|
||||||
values.delete(`${_userId}:${_boardId}`);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@homarr/redis", () => ({
|
|
||||||
setQueryCacheAsync: redisMock.setQueryCacheAsync,
|
|
||||||
removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
redisMock.values.clear();
|
|
||||||
redisMock.setQueryCacheAsync.mockClear();
|
|
||||||
redisMock.removeQueryCacheAsync.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSession = (userId = createId()): Session => ({
|
describe("isWidgetDataQueryKey", () => {
|
||||||
user: {
|
|
||||||
id: userId,
|
|
||||||
permissions: [],
|
|
||||||
colorScheme: "light",
|
|
||||||
},
|
|
||||||
expires: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const insertUserAsync = async (db: Database, id: string) => {
|
|
||||||
await db.insert(users).values({ id });
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("isPersistableWidgetQueryKey", () => {
|
|
||||||
test.each([
|
test.each([
|
||||||
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
|
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
|
||||||
[[["app", "byId"], { type: "query" }], true],
|
[[["app", "byId"], { type: "query" }], true],
|
||||||
@@ -72,77 +27,22 @@ describe("isPersistableWidgetQueryKey", () => {
|
|||||||
[[["app", "selectable"], { type: "query" }], false],
|
[[["app", "selectable"], { type: "query" }], false],
|
||||||
[[["board", "getBoardByName"], { type: "query" }], false],
|
[[["board", "getBoardByName"], { type: "query" }], false],
|
||||||
[["widget"], false],
|
[["widget"], false],
|
||||||
])("matches persist filter for %j", (queryKey, expected) => {
|
])("matches widget data queries for %j", (queryKey, expected) => {
|
||||||
expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
|
expect(isWidgetDataQueryKey(queryKey)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("queryCacheRouter", () => {
|
describe("isWidgetDataTrpcPath", () => {
|
||||||
test("stores and removes data for a board viewer", async () => {
|
test.each([
|
||||||
const db = createDb();
|
["widget.calendar.findAllEvents", true],
|
||||||
const session = createSession();
|
["app.byIds", true],
|
||||||
await insertUserAsync(db, session.user.id);
|
["integration.byIds", true],
|
||||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
|
["docker.getContainers", true],
|
||||||
|
["widget.app.ping", false],
|
||||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
["widget.beszel.getSystemStats", false],
|
||||||
|
["board.getBoardByName", false],
|
||||||
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).resolves.toEqual({
|
])("classifies dashboard data path %s", (path, expected) => {
|
||||||
stored: true,
|
expect(isWidgetDataTrpcPath(path)).toBe(expected);
|
||||||
});
|
|
||||||
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
|
|
||||||
session.user.id,
|
|
||||||
"board-1",
|
|
||||||
"cached-value",
|
|
||||||
expect.any(Number),
|
|
||||||
queryCacheMaxValueBytes,
|
|
||||||
);
|
|
||||||
|
|
||||||
await caller.removeItem({ boardId: "board-1" });
|
|
||||||
expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not touch Redis when board access is denied", async () => {
|
|
||||||
const db = createDb();
|
|
||||||
await insertUserAsync(db, "owner");
|
|
||||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
|
|
||||||
|
|
||||||
redisMock.setQueryCacheAsync.mockClear();
|
|
||||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
|
|
||||||
|
|
||||||
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
|
|
||||||
expect(redisMock.setQueryCacheAsync).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("scopes anonymous public-board cache entries by anonymous user", async () => {
|
|
||||||
const db = createDb();
|
|
||||||
await insertUserAsync(db, "owner");
|
|
||||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
|
|
||||||
|
|
||||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
|
|
||||||
|
|
||||||
await caller.setItem({ boardId: "board-1", value: "cached-value" });
|
|
||||||
|
|
||||||
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
|
|
||||||
"anonymous",
|
|
||||||
"board-1",
|
|
||||||
"cached-value",
|
|
||||||
expect.any(Number),
|
|
||||||
queryCacheMaxValueBytes,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects oversized values", async () => {
|
|
||||||
const db = createDb();
|
|
||||||
const session = createSession("user-1");
|
|
||||||
await insertUserAsync(db, session.user.id);
|
|
||||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
|
|
||||||
|
|
||||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
|
||||||
const value = "x".repeat(queryCacheMaxValueBytes + 1);
|
|
||||||
|
|
||||||
await expect(caller.setItem({ boardId: "board-1", value })).resolves.toEqual({
|
|
||||||
stored: false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,3 +76,38 @@ describe("query cache stale handling", () => {
|
|||||||
expect(queryFn).toHaveBeenCalledOnce();
|
expect(queryFn).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("dashboard supporting query policies", () => {
|
||||||
|
test("bounds metadata staleness without adding per-widget timers", () => {
|
||||||
|
expect(dashboardSupportingQueryPolicies).toEqual([
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
import { db, eq } from "@homarr/db";
|
||||||
|
import { users } from "@homarr/db/schema";
|
||||||
|
|
||||||
|
// Keep the authenticated user lookup shared across nested RSC layouts. React
|
||||||
|
// clears this memo between requests, so authorization changes cannot leak.
|
||||||
|
export const getRscUserSettingsAsync = cache(async (userId: string) =>
|
||||||
|
db.query.users.findFirst({
|
||||||
|
columns: {
|
||||||
|
homeBoardId: true,
|
||||||
|
mobileHomeBoardId: true,
|
||||||
|
firstDayOfWeek: true,
|
||||||
|
pingIconsEnabled: true,
|
||||||
|
enableRightClickOnWidgets: true,
|
||||||
|
defaultSearchEngineId: true,
|
||||||
|
openSearchInNewTab: true,
|
||||||
|
ddgBangs: true,
|
||||||
|
completedManageTour: true,
|
||||||
|
completedBoardTour: true,
|
||||||
|
},
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
+13
-16
@@ -3,24 +3,18 @@ import type { NextAuthConfig } from "next-auth";
|
|||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { eq, inArray } from "@homarr/db";
|
import { eq } from "@homarr/db";
|
||||||
import { groupMembers, groupPermissions, users } from "@homarr/db/schema";
|
import { groupMembers, groupPermissions, users } from "@homarr/db/schema";
|
||||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
import { getPermissionsWithChildren } from "@homarr/definitions";
|
||||||
|
|
||||||
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
|
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
|
||||||
const dbGroupMembers = await db.query.groupMembers.findMany({
|
|
||||||
where: eq(groupMembers.userId, userId),
|
|
||||||
});
|
|
||||||
const groupIds = dbGroupMembers.map((groupMember) => groupMember.groupId);
|
|
||||||
|
|
||||||
if (groupIds.length === 0) return [];
|
|
||||||
|
|
||||||
const dbGroupPermissions = await db
|
const dbGroupPermissions = await db
|
||||||
.selectDistinct({
|
.selectDistinct({
|
||||||
permission: groupPermissions.permission,
|
permission: groupPermissions.permission,
|
||||||
})
|
})
|
||||||
.from(groupPermissions)
|
.from(groupPermissions)
|
||||||
.where(inArray(groupPermissions.groupId, groupIds));
|
.innerJoin(groupMembers, eq(groupPermissions.groupId, groupMembers.groupId))
|
||||||
|
.where(eq(groupMembers.userId, userId));
|
||||||
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
|
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
|
||||||
|
|
||||||
return getPermissionsWithChildren(permissionKeys);
|
return getPermissionsWithChildren(permissionKeys);
|
||||||
@@ -43,12 +37,15 @@ export const createSessionAsync = async (
|
|||||||
|
|
||||||
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
|
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
|
||||||
return async ({ session, user }) => {
|
return async ({ session, user }) => {
|
||||||
const additionalProperties = await db.query.users.findFirst({
|
const [additionalProperties, permissions] = await Promise.all([
|
||||||
where: eq(users.id, user.id),
|
db.query.users.findFirst({
|
||||||
columns: {
|
where: eq(users.id, user.id),
|
||||||
colorScheme: true,
|
columns: {
|
||||||
},
|
colorScheme: true,
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
getCurrentUserPermissionsAsync(db, user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
@@ -57,7 +54,7 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
|
|||||||
...additionalProperties,
|
...additionalProperties,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
permissions: await getCurrentUserPermissionsAsync(db, user.id),
|
permissions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,14 +3,9 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
|
||||||
|
|
||||||
interface IntegrationContextProps {
|
interface IntegrationContextProps {
|
||||||
integrations: {
|
integrations: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
kind: IntegrationKind;
|
|
||||||
permissions: {
|
permissions: {
|
||||||
hasFullAccess: boolean;
|
hasFullAccess: boolean;
|
||||||
hasInteractAccess: boolean;
|
hasInteractAccess: boolean;
|
||||||
|
|||||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user