Compare commits

..
Author SHA1 Message Date
Thomas Camlong ae2a1df453 fix(dev): preserve background build errors 2026-08-02 08:26:56 +02:00
Thomas Camlong ca7e50fb96 docs(dev): explain local PR preference 2026-08-02 08:19:45 +02:00
Thomas Camlong db6aa0b26e feat(dev): prefer current local PR builds 2026-08-02 08:19:29 +02:00
Thomas Camlong d096aecd59 feat(dev): track pull request head revisions 2026-08-02 08:19:12 +02:00
Thomas Camlong 49a41431b9 docs(dev): document dashboard navigation 2026-08-01 23:49:23 +02:00
Thomas Camlong df587ec76a feat(dev): toggle dashboard screens 2026-08-01 23:49:23 +02:00
Thomas Camlong 3b450eb04a feat(dev): launch browser by default 2026-08-01 23:49:23 +02:00
Thomas 406fb4c0b6 Merge branch 'dev' into feat/dev-cli-tool 2026-08-01 23:46:16 +02:00
Thomas Camlong f23c35645c fix(dev): surface registry pull errors 2026-08-01 23:40:41 +02:00
Thomas Camlong 667bf62396 docs(dev): document remote PR builds 2026-08-01 23:36:38 +02:00
Thomas Camlong c5a056b8e5 feat(dev): build remote pull requests locally 2026-08-01 23:35:08 +02:00
Thomas Camlong 934b6e69f4 fix(dev): shallow clone pull requests 2026-08-01 23:35:08 +02:00
Thomas Camlong c8c5dbc506 fix(dev): cache GitHub pull requests 2026-08-01 23:35:08 +02:00
Thomas Camlong b1e79e4641 fix(dev): keep optional tools optional 2026-08-01 14:01:52 +02:00
Thomas Camlong 5b04ed1ab5 refactor(dev): make setup explicit and portable 2026-07-31 23:52:14 +02:00
Thomas Camlong b733ed6a79 fix(dev): make key creation atomic 2026-07-31 15:28:00 +02:00
Thomas Camlong ae3e9f7793 fix(dev): persist unique local encryption key 2026-07-31 15:11:38 +02:00
Thomas Camlong c8485ed7fc fix(dev): address CLI review feedback 2026-07-31 14:53:43 +02:00
Thomas Camlong e97eb7fc58 feat(dev): add Docker development CLI 2026-07-31 14:28:31 +02:00
187 changed files with 3216 additions and 12240 deletions

No files matched your search

+1 -2
View File
@@ -17,8 +17,7 @@ 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.*
+1 -1
View File
@@ -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 --concurrency=2 run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
- name: Validate workspace configuration - name: Validate workspace configuration
run: pnpm lint:ws run: pnpm lint:ws
-1
View File
@@ -78,4 +78,3 @@ skills-lock.json
apps/docs/.docusaurus apps/docs/.docusaurus
apps/docs/build apps/docs/build
.pnpm-store .pnpm-store
benchmark-results
+8 -18
View File
@@ -3,18 +3,12 @@
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
# Native dependencies normally use prebuilds, but the build must remain RUN apk add --no-cache libc6-compat curl bash && apk update
# 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 .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY 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
@@ -25,16 +19,14 @@ COPY --parents ./packages/definitions/src ./
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 --ignore-scripts && \ pnpm fetch && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
# Install only from the fetched, committed lockfile so local and Docker builds
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
COPY . . COPY . .
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive
ARG SKIP_ENV_VALIDATION='true' ARG SKIP_ENV_VALIDATION='true'
ARG CI='true' ARG CI='true'
@@ -45,9 +37,7 @@ 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 \
--mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \ TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
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 24.18 or newer**: You can download it from the [official website](https://nodejs.org/). - **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm version. - **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
- **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,10 +49,7 @@ This guide will help you set up your development environment to start contributi
#### During development #### During development
- `pnpm dev`: Start the Next.js development server. - `pnpm dev`: Start the 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.
@@ -77,5 +74,4 @@ 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,13 +25,15 @@ 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 Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | | ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | | ``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
@@ -41,9 +43,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`
@@ -51,27 +53,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_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_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` | | ``DB_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` |
| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` | | ``DB_HOST`` | Database host to connect to. | Any valid database host | - |
| `DB_HOST` | Database host to connect to. | Any valid database host | - | | ``DB_PORT`` | Database port to connect to. | Any valid database port | - |
| `DB_PORT` | Database port to connect to. | Any valid database port | - | | ``DB_NAME`` | Database name to connect to. | Any valid database name | - |
| `DB_NAME` | Database name to connect to. | Any valid database name | - | | ``DB_USER`` | Database user to connect with. | Any valid database user | - |
| `DB_USER` | Database user to connect with. | Any valid database user | - | | ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - |
| `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).
@@ -81,23 +83,25 @@ 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
@@ -105,6 +109,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 |
+11 -22
View File
@@ -1,6 +1,4 @@
// 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";
@@ -15,21 +13,7 @@ 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",
}, },
@@ -46,14 +30,19 @@ 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"],
turbopackFileSystemCacheForBuild: true, turbopackFileSystemCacheForDev: true,
useTypeScriptCli: true, webpackMemoryOptimizations: true,
}, },
turbopack: { turbopack: {
root: path.resolve(import.meta.dirname, "../.."), // ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
// Development runs tasks and WebSocket as separate processes. These aliases // src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
// keep their production-only instrumentation imports out of the dev graph. // (see vercel/next.js#95125). Suppress until upstream fix lands.
resolveAlias: getDevelopmentServiceAliases(), ignoreIssue: [
{
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: {
+2 -1
View File
@@ -59,10 +59,11 @@
"@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/react-devtools": "catalog:", "@tanstack/query-async-storage-persister": "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:",
@@ -1,120 +0,0 @@
"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} />
);
@@ -0,0 +1,32 @@
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),
});
@@ -1,27 +0,0 @@
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);
});
});
@@ -1,20 +0,0 @@
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;
};
@@ -1,52 +0,0 @@
// @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());
}
});
});
@@ -1,39 +0,0 @@
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,10 +1,12 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useEffect, useMemo, useState } from "react"; import { useState } from "react";
import dynamic from "next/dynamic"; import type { QueryKey } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient } 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,
@@ -23,24 +25,18 @@ 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 {
dashboardSupportingQueryPolicies, isPersistableWidgetQueryKey,
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 { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { widgetImports } from "@homarr/widgets";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; import { createWidgetQueryPersister } from "./query-cache-persister";
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") {
@@ -63,46 +59,21 @@ const constructWebsocketUrl = () => {
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`; return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
}; };
export function TRPCReactProvider({ children }: PropsWithChildren) { const wsClient = createWSClient({
const { data: session } = useSession(); url: constructWebsocketUrl(),
const sessionQueryScope = getSessionQueryScope(session); });
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
return ( export function TRPCReactProvider(props: PropsWithChildren) {
<SessionQueryScopeGuard const [persister] = useState(() => createWidgetQueryPersister());
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,
retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3), refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 3,
}, },
mutations: { mutations: {
onError(error) { onError(error) {
@@ -120,29 +91,23 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
}, },
}, },
}); });
client.setQueryDefaults([["widget"]], { client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
refetchInterval: queryCacheDefaultRefetchIntervalMs, for (const { definition } of Object.values(widgetImports)) {
refetchOnWindowFocus: false, const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
refetchOnReconnect: false, if (def.refetchInterval === undefined) continue;
}); const key = def.queryKey ?? [["widget", def.kind]];
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) { const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
client.setQueryDefaults(queryKey, { client.setQueryDefaults(key, { refetchInterval: interval });
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
});
}
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
client.setQueryDefaults(queryKey, policy);
} }
return client; return client;
}); });
useEffect(() => () => queryClient.clear(), [queryClient]); const [trpcClient] = useState(() => {
const trpcClient = useMemo(() => {
return clientApi.createClient({ return clientApi.createClient({
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => opts.direction === "down" && opts.result instanceof Error, enabled: (opts) =>
env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
}), }),
splitLink({ splitLink({
condition: ({ type }) => type === "subscription", condition: ({ type }) => type === "subscription",
@@ -182,14 +147,25 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
}), }),
], ],
}); });
}, [wsClient]); });
return ( return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}> <clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <PersistQueryClientProvider
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration> client={queryClient}
{process.env.NODE_ENV === "development" && <DevelopmentTools />} persistOptions={{
</QueryClientProvider> persister,
buster: queryCacheBuster,
maxAge: queryCacheDefaultGcTimeMs,
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
},
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</PersistQueryClientProvider>
</clientApi.Provider> </clientApi.Provider>
); );
}; }
@@ -1,9 +1,9 @@
import { getHomeBoardAsync } from "@homarr/api/board-server"; import { api } from "@homarr/api/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 getHomeBoardAsync(); return await api.board.getHomeBoard();
}, },
}); });
@@ -1,9 +1,9 @@
import { getBoardByNameAsync } from "@homarr/api/board-server"; import { api } from "@homarr/api/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 getBoardByNameAsync(name); return await api.board.getBoardByName({ name });
}, },
}); });
@@ -1,84 +0,0 @@
"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")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return ( return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <Box h="100%" pos="relative">
<BoardBackgroundVideo /> <BoardBackgroundVideo />
<Stack h="100%"> <Stack h="100%">
{fullWidthSortedSections.map((section) => {fullWidthSortedSections.map((section) =>
@@ -1,12 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cache } from "react"; import { Suspense } 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 { makeQueryClient } from "@homarr/api/shared"; import type { PersistedClient } from "@tanstack/react-query-persist-client";
import superjson from "superjson";
import { queryCacheBuster } from "@homarr/api/query-cache";
import { getQueryClient } from "@homarr/api/server";
import { 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";
@@ -14,6 +17,7 @@ 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";
@@ -25,7 +29,6 @@ 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>;
@@ -58,6 +61,7 @@ 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]) =>
@@ -73,12 +77,19 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
), ),
]); ]);
const userId = session?.user.id ?? "anonymous";
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <>
<IntegrationProvider integrations={integrations}> <Suspense>
<DynamicClientBoard /> <QueryCacheHydration userId={userId} boardId={board.id} />
</IntegrationProvider> </Suspense>
</HydrationBoundary> <HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
</>
); );
}, },
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => { generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
@@ -109,3 +120,19 @@ 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,24 +1,7 @@
"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,36 +1,45 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react"; import {
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 } from "@homarr/modals"; import { useConfirmModal, useModalAction } 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();
@@ -43,16 +52,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return ( return (
<> <>
{isEditMode && <BoardAddMenu />} {isEditMode && <AddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} /> <EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && ( {!demoReadOnly && (
<TourTarget id="board-settings"> <OnboardingTour.Target 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>
</TourTarget> </OnboardingTour.Target>
)} )}
<SelectBoardsMenu /> <SelectBoardsMenu />
@@ -60,6 +69,94 @@ 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();
@@ -100,29 +197,21 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
return ( return (
<TourTarget id="board-edit-mode"> <OnboardingTour.Target id="board-edit-mode">
<HeaderButton <HeaderButton onClick={toggle} loading={isPending}>
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>
</TourTarget> </OnboardingTour.Target>
); );
}; };
const SelectBoardsMenu = () => { const SelectBoardsMenu = () => {
const [isOpen, setIsOpen] = useState(false); const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
return ( return (
<TourTarget id="board-switcher"> <OnboardingTour.Target id="board-switcher">
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}> <Box>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}> <Menu position="bottom-end">
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4}> <HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} /> <IconReplace stroke={1.5} />
@@ -130,11 +219,6 @@ 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}
@@ -149,7 +233,7 @@ const SelectBoardsMenu = () => {
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Box> </Box>
</TourTarget> </OnboardingTour.Target>
); );
}; };
@@ -162,13 +246,15 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
useEffect(() => { useEffect(() => {
if (!isDirty) return; if (!isDirty) return;
const handleClick = (event: MouseEvent) => { const handleClick = (event: Event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; const target = (event.target as HTMLElement).closest("a");
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
if (!target) return; if (!target) {
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"),
@@ -194,14 +280,19 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.returnValue = true; event.returnValue = true;
}; };
document.addEventListener("click", handleClick, true); const anchors = document.querySelectorAll(anchorSelector);
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 () => {
document.removeEventListener("click", handleClick, true); anchors.forEach((link) => {
link.removeEventListener("click", handleClick);
});
window.removeEventListener("popstate", handlePopState); window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
}; };
}, [isDirty, openConfirmModal, router, t]); }, [isDirty]);
}; };
@@ -1,4 +1,4 @@
import { getBoardByNameAsync } from "@homarr/api/board-server"; import { api } from "@homarr/api/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 getBoardByNameAsync(name); return await api.board.getBoardByName({ name });
}, },
}); });
@@ -3,7 +3,6 @@ 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";
@@ -12,8 +11,7 @@ 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 { BoardTourGate } from "~/components/onboarding/board-tour-gate"; import { BoardTourProvider } from "~/components/onboarding/board-tour";
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";
@@ -23,6 +21,11 @@ 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>;
@@ -40,32 +43,8 @@ export const createBoardLayout = <TParams extends Params>({
}: PropsWithChildren<{ }: PropsWithChildren<{
params: Promise<TParams>; params: Promise<TParams>;
}>) => { }>) => {
const resolvedParams = await params; const session = await auth();
const sessionPromise = auth(); const initialBoard = await getInitialBoard(await params).catch((error) => {
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");
@@ -81,8 +60,8 @@ export const createBoardLayout = <TParams extends Params>({
} }
throw error; throw error;
} });
const initialBoard = initialBoardResult.board; const colorScheme = await getCurrentColorSchemeAsync();
return ( return (
<BoardProvider initialBoard={initialBoard}> <BoardProvider initialBoard={initialBoard}>
@@ -90,7 +69,7 @@ export const createBoardLayout = <TParams extends Params>({
<EditModeProvider> <EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}> <BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss /> <CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}> <BoardTourWrapper hasSession={withTour && !!session}>
<ClientShell hasNavigation={false}> <ClientShell hasNavigation={false}>
<MainHeader <MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
@@ -99,7 +78,7 @@ export const createBoardLayout = <TParams extends Params>({
/> />
<AppShellMain>{children}</AppShellMain> <AppShellMain>{children}</AppShellMain>
</ClientShell> </ClientShell>
</BoardTourGate> </BoardTourWrapper>
</BoardMantineProvider> </BoardMantineProvider>
</EditModeProvider> </EditModeProvider>
</BoardReadyProvider> </BoardReadyProvider>
+2 -9
View File
@@ -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,16 +29,9 @@ 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 getCurrentOnboardingStepAsync(); const currentStep = await api.onboard.currentStep();
const CurrentComponent = stepComponents[currentStep.current]; const CurrentComponent = stepComponents[currentStep.current];
+11 -23
View File
@@ -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 { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; import { api } from "@homarr/api/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 { createLogger } from "@homarr/core/infrastructure/logs"; import { db } from "@homarr/db";
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,8 +43,6 @@ 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",
@@ -80,27 +78,15 @@ export default async function Layout(props: {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ locale: SupportedLanguage }>; params: Promise<{ locale: SupportedLanguage }>;
}) { }) {
const { locale } = await props.params; if (!isLocaleSupported((await props.params).locale)) {
if (!isLocaleSupported(locale)) {
notFound(); notFound();
} }
const sessionPromise = auth(); const session = await auth();
const userPromise = sessionPromise.then((session) => const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
session const serverSettings = await getServerSettingsAsync(db);
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { const colorScheme = await getCurrentColorSchemeAsync();
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
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) => {
@@ -139,6 +125,8 @@ 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
+6 -28
View File
@@ -27,10 +27,8 @@ 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";
@@ -41,29 +39,11 @@ 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 { ManageTourGate } from "~/components/onboarding/manage-tour-gate"; import { ManageTourProvider } from "~/components/onboarding/manage-tour";
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 sessionPromise = auth(); const t = await getScopedI18n("management.navbar");
const shouldRunManageTourPromise = sessionPromise.then(async (session) => { const session = await auth();
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"),
@@ -245,9 +225,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
</ClientShell> </ClientShell>
); );
return ( if (!session) return shell;
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
{shell} return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
</ManageTourGate>
);
} }
@@ -1,6 +1,5 @@
"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";
@@ -10,7 +9,6 @@ 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({
@@ -20,7 +18,6 @@ export const ResetTours = () => {
message: t("resetSuccess.message"), message: t("resetSuccess.message"),
}); });
void utils.user.getTourStatus.invalidate(); void utils.user.getTourStatus.invalidate();
router.refresh();
}, },
}); });
@@ -1,38 +1,41 @@
"use client"; "use client";
import { use, useCallback, useState } from "react"; import { useCallback, useMemo, 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 { clientApi } from "@homarr/api/client"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { 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 { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { WidgetEditModal } from "@homarr/widgets/modals";
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 }: WidgetPreviewPageContentProps) => { export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
const settings = useSettings(); const settings = useSettings();
const t = useI18n(); const t = useI18n();
const utils = clientApi.useUtils(); const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const currentDefinition = useMemo(() => widgetImports[kind].definition, [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,
@@ -42,7 +45,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
integrationIds: string[]; integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions; advancedOptions: BoardItemAdvancedOptions;
}>({ }>({
options: reduceWidgetOptionsWithDefinition(currentDefinition, settings, {}), options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
integrationIds: [], integrationIds: [],
advancedOptions: { advancedOptions: {
title: null, title: null,
@@ -51,41 +54,31 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
}, },
}); });
const handleOpenEditWidgetModal = useCallback(async () => { const handleOpenEditWidgetModal = useCallback(() => {
const hasIntegrationSupport = "supportedIntegrations" in currentDefinition; openWidgetEditModal(
setIsEditorLoading(true); {
preloadWidgetEditModal(); kind,
try { value: state,
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : []; onSuccessfulEdit: (value) => {
openWidgetEditModal( setState(value);
{
kind,
definition: currentDefinition,
value: state,
onSuccessfulEdit: (value) => {
setState(value);
},
integrationData: integrationData.filter((integration) =>
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
),
integrationSupport: hasIntegrationSupport,
settings,
}, },
{ integrationData: integrationData.filter(
title(translate) { (integration) =>
return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`; "supportedIntegrations" in currentDefinition &&
}, (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
},
{
title(t) {
return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`;
}, },
); },
} catch (error) { );
showErrorNotification({ }, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
title: t("common.error"),
message: error instanceof Error ? error.message : String(error), const Comp = loadWidgetDynamic(kind);
});
} finally {
setIsEditorLoading(false);
}
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
const toggleEditMode = useCallback(() => { const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode); setEditMode((editMode) => !editMode);
@@ -101,11 +94,8 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
}); });
}, [dimensions, openPreviewDimensionsModal]); }, [dimensions, openPreviewDimensionsModal]);
const updateOptions = useCallback( const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
({ newOptions }: { newOptions: Record<string, unknown> }) => setState({ ...state, options: { ...state.options, newOptions } });
setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
[],
);
return ( return (
<> <>
@@ -115,10 +105,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
<ErrorBoundary <ErrorBoundary
onReset={reset} onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => ( fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={currentDefinition} error={error} resetErrorBoundary={resetErrorBoundary} /> <WidgetError kind={kind} error={error} resetErrorBoundary={resetErrorBoundary} />
)} )}
> >
<Component <Comp
options={state.options as never} options={state.options as never}
integrationIds={state.integrationIds} integrationIds={state.integrationIds}
width={dimensions.width} width={dimensions.width}
@@ -137,10 +127,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
size={48} size={48}
variant="default" variant="default"
radius="xl" radius="xl"
onClick={() => void handleOpenEditWidgetModal()} onClick={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,18 +1,23 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { widgetKinds } from "@homarr/definitions"; import { widgetImports } from "@homarr/widgets";
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 links = widgetKinds.map((kind) => ({ const getLinks = () => {
href: `/widgets/${kind}`, return Object.entries(widgetImports).map(([key, value]) => {
icon: widgetCatalogIcons[kind], return {
label: kind, href: `/widgets/${key}`,
})); 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,9 +1,10 @@
import { Suspense } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Center, Loader } from "@mantine/core"; import { Center } 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 { widgetKinds } from "@homarr/widgets/manifest"; import { widgetImports } from "@homarr/widgets";
import { WidgetPreviewPageContent } from "./_content"; import { WidgetPreviewPageContent } from "./_content";
@@ -12,16 +13,24 @@ interface Props {
} }
export default async function WidgetPreview(props: Props) { export default async function WidgetPreview(props: Props) {
const { kind } = await props.params; if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
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">
<Suspense fallback={<Loader size="sm" />}> <WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
</Suspense>
</Center> </Center>
); );
} }
@@ -1,21 +1,17 @@
import type { ComponentType, MutableRefObject } from "react"; import type { MutableRefObject } from "react";
import { Suspense, use, useRef } from "react"; import { useRef } from "react";
import type { CardProps } from "@mantine/core"; import { Badge, Card } 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";
@@ -23,7 +19,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 { removeWidgetDataQueries } from "./widget-query-recovery"; import { removePersistedWidgetQueries } from "./widget-query-recovery";
interface BoardItemContentProps { interface BoardItemContentProps {
item: SectionItem; item: SectionItem;
@@ -35,53 +31,6 @@ 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();
@@ -89,28 +38,29 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
return ( return (
<> <>
<ErrorBoundary <WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
resetKeys={[item.kind]} <Card
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />} ref={ref}
> className={combineClasses(
<Suspense classes.itemCard,
fallback={ `${item.kind}-wrapper`,
<BoardItemCard item={item} innerRef={ref}> "grid-stack-item-content",
<Center h="100%"> item.advancedOptions.customCssClasses.join(" "),
<Loader size="sm" /> )}
</Center> radius={board.itemRadius}
</BoardItemCard> styles={{
} root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
> >
<LoadedBoardItemContent <InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
item={item} </Card>
width={width} </WidgetContextMenu>
height={height}
widgetStateRef={widgetStateRef}
innerRef={ref}
/>
</Suspense>
</ErrorBoundary>
{item.advancedOptions.title?.trim() && ( {item.advancedOptions.title?.trim() && (
<Badge <Badge
pos="absolute" pos="absolute"
@@ -136,72 +86,40 @@ 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, definition, Component, ...dimensions }: InnerContentProps) => { const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const settings = useSettings(); const settings = useSettings();
const board = useRequiredBoard(); const board = useRequiredBoard();
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options); const Comp = loadWidgetDynamic(item.kind);
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 ?? 0) >= 1; "supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
return ( return (
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
onReset={() => { onReset={() => {
removeWidgetDataQueries(queryClient, widgetQueryKeys); removePersistedWidgetQueries(queryClient);
reset(); reset();
}} }}
fallbackRender={({ resetErrorBoundary, error }) => ( fallbackRender={({ resetErrorBoundary, error }) => (
<> <>
<BoardItemMenu <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
offset={4} <WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
item={newItem}
definition={definition}
resetErrorBoundary={resetErrorBoundary}
/>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</> </>
)} )}
> >
@@ -213,25 +131,23 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false) (!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
} }
/> />
<BoardItemMenu offset={4} item={newItem} definition={definition} /> <BoardItemMenu offset={4} item={newItem} />
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}> <Comp
<Component options={options as never}
options={options as never} integrationIds={item.integrationIds}
integrationIds={item.integrationIds} isEditMode={isEditMode}
isEditMode={isEditMode} boardId={board.id}
boardId={board.id} itemId={item.id}
itemId={item.id} setOptions={(partialNewOptions) =>
setOptions={(partialNewOptions) => updateOptions({
updateOptions({ newOptions: {
newOptions: { ...options,
...options, ...partialNewOptions.newOptions,
...partialNewOptions.newOptions, },
}, })
}) }
} {...dimensions}
{...dimensions} />
/>
</div>
</ErrorBoundary> </ErrorBoundary>
)} )}
</QueryErrorResetBoundary> </QueryErrorResetBoundary>
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef } 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,44 +8,39 @@ 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 type { WidgetDefinition } from "@homarr/widgets/definition"; import { widgetImports } from "@homarr/widgets";
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 { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal"; import { ItemMoveModal } from "./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();
const [isEditMode] = useEditMode(); if (!session) return null;
if (!session || !isEditMode) return null;
return <BoardItemMenuInner {...props} />; return <BoardItemMenuInner {...props} />;
}; };
const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: BoardItemMenuProps) => { const BoardItemMenuInner = ({ offset, item, 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(LazyWidgetEditModal); const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal); const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isEditMode] = useEditMode();
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(undefined, { const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
enabled: isMenuOpen && hasSupportedIntegrations, const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
});
const { gridstack } = useSectionContext().refs; const { gridstack } = useSectionContext().refs;
const settings = useSettings(); const settings = useSettings();
@@ -57,11 +52,12 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
} }
}, [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,
@@ -84,10 +80,10 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
}, },
integrationData: (integrationData ?? []).filter( integrationData: (integrationData ?? []).filter(
(integration) => (integration) =>
"supportedIntegrations" in definition && "supportedIntegrations" in currentDefinition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
), ),
integrationSupport: "supportedIntegrations" in definition, integrationSupport: "supportedIntegrations" in currentDefinition,
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,
@@ -111,7 +107,7 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
}; };
return ( return (
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}> <Menu withinPortal position="right-start" arrowPosition="center">
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon
variant="default" variant="default"
@@ -127,19 +123,11 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
</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 <Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
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";
export interface ItemMoveModalProps { interface InnerProps {
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<ItemMoveModalProps>(({ actions, innerProps }) => { export const ItemMoveModal = createModal<InnerProps>(({ 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(
@@ -1,48 +0,0 @@
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();
});
});
@@ -1,28 +0,0 @@
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));
};
@@ -1,21 +0,0 @@
.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,65 +1,46 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, 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 } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions"; import { getIconUrl, getIntegrationName } 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 { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { WidgetEditModal } from "@homarr/widgets/modals";
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";
interface ItemSelectModalContentProps { export const ItemSelectModal = createModal<void>(({ actions }) => {
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(LazyWidgetEditModal); const { openModal: openEditModal } = useModalAction(WidgetEditModal);
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(
() => () =>
widgetKinds objectEntries(widgetImports)
.filter((kind) => kind !== "customApi") .filter(([kind]) => kind !== "customApi")
.map((kind) => { .map(([kind, value]) => ({
return { kind,
kind, supportedIntegrations:
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter( "supportedIntegrations" in value.definition
(integration) => integration !== "mock", ? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
), : [],
icon: widgetCatalogIcons[kind], icon: value.definition.icon,
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;
@@ -85,81 +66,52 @@ const ItemSelectModalContent = ({
[customWidgetDefs, search], [customWidgetDefs, search],
); );
const notifyDefinitionLoadError = (error: unknown) => { const handleAddCustomWidget = (definitionId: string) => {
showErrorNotification({ const itemId = createId();
title: t("common.error"), const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
message: error instanceof Error ? error.message : String(error), createItem({ id: itemId, kind: "customApi", integrationIds: [] });
}); updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
actions.closeModal();
}; };
const handleAddCustomWidget = async (definitionId: string) => { const handleAdd = (kind: WidgetKind) => {
if (!tryLockSelection(selectionLock)) return; const definition = widgetImports[kind].definition;
setLoadingSelection(`custom:${definitionId}`); const hasIntegrationSupport = "supportedIntegrations" in definition;
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 handleAdd = async (kind: WidgetKind) => { const matchingIntegrations = hasIntegrationSupport
if (!tryLockSelection(selectionLock)) return; ? (integrationData ?? []).filter((integration) =>
setLoadingSelection(kind); (definition.supportedIntegrations as string[]).includes(integration.kind),
preloadWidgetEditModal(); )
try { : [];
const definition = await loadWidgetDefinition(kind);
const hasIntegrationSupport = "supportedIntegrations" in definition;
const matchingIntegrations = await resolveMatchingIntegrationsAsync({ const integrationIds = matchingIntegrations.map((i) => i.id);
hasIntegrationSupport, const itemId = createId();
supportedIntegrations: definition.supportedIntegrations ?? [], const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
currentData: integrationData,
ensureDataAsync: ensureIntegrationDataAsync,
});
const integrationIds = matchingIntegrations.map((i) => i.id); createItem({ id: itemId, kind, integrationIds });
const itemId = createId(); actions.closeModal();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
createItem({ id: itemId, kind, integrationIds }); openEditModal(
actions.closeModal(); {
kind,
openEditModal( value: {
{ advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
kind, options: defaultOptions,
definition, integrationIds,
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 }) => {
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
}, },
); integrationData: matchingIntegrations,
} catch (error) { integrationSupport: hasIntegrationSupport,
notifyDefinitionLoadError(error); settings,
} finally { },
unlockSelection(selectionLock); {
setLoadingSelection(null); title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
} },
);
}; };
return ( return (
@@ -168,9 +120,9 @@ const ItemSelectModalContent = ({
onSearchChange={setSearch} onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`} placeholder={`${t("item.create.search")}...`}
onSearchKeyDown={(event) => { onSearchKeyDown={(event) => {
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { if (event.key === "Enter" && filteredItems.length === 1) {
const [item] = filteredItems; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (item) void handleAdd(item.kind); handleAdd(filteredItems[0]!.kind);
} }
}} }}
> >
@@ -178,13 +130,7 @@ const ItemSelectModalContent = ({
<WidgetItem <WidgetItem
key={item.kind} key={item.kind}
item={item} item={item}
onSelect={() => void handleAdd(item.kind)} onSelect={() => 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))}
/> />
))} ))}
@@ -200,12 +146,12 @@ const ItemSelectModalContent = ({
{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" }} style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)} onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
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">
@@ -223,24 +169,18 @@ const ItemSelectModalContent = ({
</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 <Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
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>
@@ -256,24 +196,7 @@ const ItemSelectModalContent = ({
)} )}
</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,
}); });
@@ -281,9 +204,6 @@ export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFram
const WidgetItem = ({ const WidgetItem = ({
item, item,
onSelect, onSelect,
onIntent,
disabled,
loading,
hasMatchingIntegration, hasMatchingIntegration,
}: { }: {
item: { item: {
@@ -294,27 +214,23 @@ 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,
}} }}
onFocus={onIntent} onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onPointerEnter={onIntent} onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
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">
@@ -331,17 +247,18 @@ 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 disabled={disabled} loading={loading}> <Button onClick={onSelect} variant="light" size="xs" fullWidth>
{t(`item.create.addToBoard`)} {t(`item.create.addToBoard`)}
</Button> </Button>
</Box> </Box>
@@ -1,67 +0,0 @@
"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,
});
@@ -1,68 +0,0 @@
"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, useState } from "react"; import { useCallback, useMemo } 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 { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; import { 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,116 +25,63 @@ 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, WidgetDefinition } from "@homarr/widgets/definition"; import type { WidgetContextMenuAction } from "@homarr/widgets";
import { getWidgetQueryKeys } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; 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 { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal"; import { ItemMoveModal } from "./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, definition, widgetStateRef, children }: WidgetContextMenuProps) => { export const WidgetContextMenu = ({ item, 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 hasSupportedIntegrations = const settings = useSettings();
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; const isRightClickEnabled = settings.enableRightClickOnWidgets;
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal); const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
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(undefined, { const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
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 widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); const widgetQueryKey = useMemo(
const isWidgetFetching = () => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
useIsFetching({ [currentDefinition, item.kind],
predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), );
}) > 0; const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => { const handleRefetch = useCallback(() => {
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" }))); void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
}, [queryClient, widgetQueryKeys]); }, [queryClient, widgetQueryKey]);
const options = useMemo( const options = useMemo(
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options), () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[definition, settings, item.options], [item.kind, settings, item.options],
); );
type OptionDef = { type: string; skipContextMenu?: boolean }; type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => { const toggleOptions = useMemo(() => {
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>; const rawOptions = currentDefinition.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);
}, [definition, settings]); }, [currentDefinition, settings]);
const widgetContextActions = useMemo(() => { const widgetContextActions = useMemo(() => {
const def = definition as unknown as Record<string, unknown>; const def = currentDefinition 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)({
@@ -147,7 +94,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
widgetStateRef, widgetStateRef,
}); });
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); }, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
const persistBoard = useCallback( const persistBoard = useCallback(
(updatedItems: typeof board.items) => { (updatedItems: typeof board.items) => {
@@ -160,7 +107,6 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
definition,
value: { value: {
advancedOptions: item.advancedOptions, advancedOptions: item.advancedOptions,
options: item.options, options: item.options,
@@ -187,10 +133,10 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
}, },
integrationData: (integrationData ?? []).filter( integrationData: (integrationData ?? []).filter(
(integration) => (integration) =>
"supportedIntegrations" in definition && "supportedIntegrations" in currentDefinition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
), ),
integrationSupport: "supportedIntegrations" in definition, integrationSupport: "supportedIntegrations" in currentDefinition,
settings, settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
@@ -204,7 +150,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
updateItemAdvancedOptions, updateItemAdvancedOptions,
updateItemIntegrations, updateItemIntegrations,
integrationData, integrationData,
definition, currentDefinition,
settings, settings,
isEditMode, isEditMode,
persistBoard, persistBoard,
@@ -222,133 +168,135 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
[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}>
{toggleOptions.length > 0 && ( <Menu.ContextMenu>{children}</Menu.ContextMenu>
<> <Menu.Dropdown>
<Menu.Label>{tMenu("options")}</Menu.Label> {toggleOptions.length > 0 && (
{toggleOptions.map(([key]) => ( <>
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}> <Menu.Label>{tMenu("options")}</Menu.Label>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {toggleOptions.map(([key]) => (
<Group wrap="nowrap"> <Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" /> <Group wrap="nowrap">
</Group> {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
</Menu.Item> <Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
))} </Group>
</>
)}
{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> </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>
</>
)}
{isEditMode && (
<> <>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />} {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Item <Menu.Item
closeMenuOnClick leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
leftSection={<IconLayoutKanban size={16} />} onClick={handleRefetch}
onFocus={preloadItemMoveModal} disabled={isWidgetFetching}
onPointerEnter={preloadItemMoveModal}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
> >
{tItem("action.moveResize")} <Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKey={widgetQueryKey}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
closeMenuOnClick closeMenuOnClick
leftSection={<IconCopy size={16} />} leftSection={<IconSettings size={16} />}
onClick={() => duplicateItem({ itemId: item.id })} onClick={openEditModal}
disabled={isPending}
> >
{tItem("action.duplicate")} {tMenu("settings")}
</Menu.Item> </Menu.Item>
</> </>
)}
<> {isEditMode && (
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />} <>
<Menu.Item <Menu.Divider />
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />} <Menu.Item
onClick={handleRefetch} closeMenuOnClick
disabled={isWidgetFetching} color="red"
> leftSection={<IconTrash size={16} />}
<Group justify="space-between" wrap="nowrap" gap="sm"> onClick={() => {
{tMenu("refresh")} openConfirmModal({
<WidgetQueryStatus title: tItem("remove.title"),
queryClient={queryClient} children: tItem("remove.message"),
queryKeys={widgetQueryKeys} onConfirm: () => removeItem({ itemId: item.id }),
isFetching={isWidgetFetching} });
t={t} }}
/> >
</Group> {tItem("action.remove")}
</Menu.Item> </Menu.Item>
<Menu.Item </>
closeMenuOnClick )}
leftSection={<IconSettings size={16} />} </Menu.Dropdown>
onClick={openEditModal} </Menu>
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;
queryKeys: QueryKey[]; queryKey: QueryKey;
isFetching: boolean; isFetching: boolean;
t: TranslationFunction; t: TranslationFunction;
} }
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))]; const queries = 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));
@@ -386,8 +334,7 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
<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")} {t("item.menu.status.error")} · {ageLabel}
{latest > 0 && ` · ${ageLabel}`}
</Text> </Text>
</Group> </Group>
</Tooltip> </Tooltip>
@@ -398,8 +345,7 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
<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")} {t("item.menu.status.success")} · {ageLabel}
{latest > 0 && ` · ${ageLabel}`}
</Text> </Text>
</Group> </Group>
); );
@@ -0,0 +1,155 @@
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,13 +1,12 @@
"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 { loadWidgetDefinition } from "@homarr/widgets/manifest"; import { widgetImports } from "@homarr/widgets";
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";
@@ -19,19 +18,8 @@ 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 = use(loadWidgetDefinition(item.kind)); const { definition } = widgetImports[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`);
@@ -40,6 +28,10 @@ const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProp
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,36 +1,27 @@
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 { removeWidgetDataQueries } from "./widget-query-recovery"; import { removePersistedWidgetQueries } from "./widget-query-recovery";
describe("removeWidgetDataQueries", () => { describe("removePersistedWidgetQueries", () => {
test("removes only the failed widget prefix and allows fresh data to be fetched", async () => { test("removes stale widget queries 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");
removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]); removePersistedWidgetQueries(queryClient);
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("uses an explicit supporting query prefix without purging other supporting data", () => { test("removes persisted queries outside the widget router", () => {
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: {} }];
@@ -40,14 +31,14 @@ describe("removeWidgetDataQueries", () => {
queryClient.setQueryData(appQueryKey, "stale"); queryClient.setQueryData(appQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated"); queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]); removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined(); expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(appQueryKey)).toBe("stale"); expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
}); });
test("uses the definition query prefix when the widget kind and query path differ", () => { test("removes persisted widget queries 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: {} }];
@@ -55,7 +46,7 @@ describe("removeWidgetDataQueries", () => {
queryClient.setQueryData(crashedWidgetQueryKey, "stale"); queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated"); queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]); removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined(); expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
@@ -1,5 +1,7 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => { import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
}; };
@@ -105,14 +105,12 @@ 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 = ( const appIds = filterByItemKind(
await filterByItemKind( board.items.filter(
board.items.filter( (item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id, ),
), settings,
settings, "app",
"app",
)
).map((item) => { ).map((item) => {
return item.options.appId; return item.options.appId;
}); });
@@ -1,23 +1,21 @@
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/definition"; import type { WidgetComponentProps } from "@homarr/widgets";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
export const filterByItemKind = async <TKind extends WidgetKind>( export const filterByItemKind = <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: reduceWidgetOptionsWithDefinition( options: reduceWidgetOptionsWithDefaultValues(
definition, kind,
settings, settings,
item.options, item.options,
) as WidgetComponentProps<TKind>["options"], ) as WidgetComponentProps<TKind>["options"],
@@ -4,8 +4,6 @@ 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";
@@ -17,11 +15,7 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => { export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext(); const { innerSections, items, refs } = useSectionContext();
const { data: session } = useSession(); const { data: integrations } = clientApi.integration.all.useQuery();
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,3 +1,4 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { import {
featuredIntegrations, featuredIntegrations,
@@ -5,8 +6,30 @@ 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",
@@ -47,11 +70,11 @@ export interface IntegrationGridItem {
export const buildSortedIntegrations = ( export const buildSortedIntegrations = (
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
): IntegrationGridItem[] => { ): IntegrationGridItem[] =>
return integrationKinds integrationKinds
.filter((kind) => { .filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false; if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
if (!options.enableMockIntegration && kind === "mock") return false; if (!options.enableMockIntegration && kind === "mock") return false;
return true; return true;
}) })
@@ -59,9 +82,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: getWidgetKindsForIntegration(kind), widgets: widgetsByIntegration[kind],
})) }))
.toSorted((left, right) => { .sort((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;
@@ -69,7 +92,6 @@ 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()));
+2 -2
View File
@@ -1,6 +1,7 @@
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";
@@ -8,7 +9,6 @@ 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>
<LazySpotlight /> <Spotlight />
</AppShellHeader> </AppShellHeader>
); );
}; };
@@ -14,9 +14,6 @@ type HeaderButtonProps = (
} }
) & { ) & {
children: ReactNode; children: ReactNode;
onFocus?: () => void;
onPointerDown?: () => void;
onPointerEnter?: () => void;
} & Partial<ActionIconProps>; } & Partial<ActionIconProps>;
const headerButtonActionIconProps: ActionIconProps = { const headerButtonActionIconProps: ActionIconProps = {
@@ -1,89 +0,0 @@
"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,28 +3,23 @@
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/open"; import { openSpotlight } from "@homarr/spotlight";
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")}...`}
@@ -34,14 +29,7 @@ export const DesktopSearchInput = () => {
export const MobileSearchButton = () => { export const MobileSearchButton = () => {
return ( return (
<HeaderButton <HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
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>
); );
@@ -1,71 +0,0 @@
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,41 +1,11 @@
"use client"; "use client";
import type { PropsWithChildren, ReactNode } from "react"; import type { ReactNode } from "react";
import { createContext, Suspense, use, useContext } from "react"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
const TourTargetsEnabledContext = createContext(false); export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
let tourLibraryPromise: Promise<typeof import("@gfazioli/mantine-onboarding-tour")> | undefined; <OnboardingTour.Target id={id}>
<Box data-tour-target={id}>{children}</Box>
const loadTourLibrary = () => { </OnboardingTour.Target>
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,6 +1,7 @@
"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";
@@ -8,28 +9,67 @@ 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 {
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined; availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
disabled: boolean; disabled: boolean;
} }
export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => { export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
if (disabled || availableUpdates === undefined) { if (disabled || availableUpdatesPromise === undefined) {
return children; return children;
} }
return ( return (
<Indicator disabled={availableUpdates.length === 0} size={15} processing withBorder> <Suspense fallback={children}>
<InnerUpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={disabled}>
{children}
</InnerUpdateIndicator>
</Suspense>
);
};
interface InnerUpdateIndicatorProps extends PropsWithChildren {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
disabled: boolean;
}
const InnerUpdateIndicator = ({ children, disabled, availableUpdatesPromise }: InnerUpdateIndicatorProps) => {
const availableUpdates = use(availableUpdatesPromise);
return (
<Indicator
disabled={!availableUpdates || availableUpdates.length === 0 || disabled}
size={15}
processing
withBorder
>
{children} {children}
</Indicator> </Indicator>
); );
}; };
interface AvailableUpdatesMenuItemProps { interface AvailableUpdatesMenuItemProps {
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined; availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
} }
export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => { export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => {
if (availableUpdatesPromise === undefined) {
return null;
}
return (
<Suspense fallback={null}>
<InnerAvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
</Suspense>
);
};
interface InnerAvailableUpdatesMenuItemProps {
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
}
const InnerAvailableUpdatesMenuItem = ({ availableUpdatesPromise }: InnerAvailableUpdatesMenuItemProps) => {
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;
} }
@@ -1,125 +0,0 @@
"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,14 +1,28 @@
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 { UserButtonClient } from "./user-client"; import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
export const UserButton = async () => { export const UserButton = async () => {
const session = await auth(); const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin")); const isAdmin = 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 (
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} /> <UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
<CurrentUserAvatar size="md" />
</UpdateIndicator>
</Suspense>
</UnstyledButton>
</UserAvatarMenu>
); );
}; };
@@ -1,19 +1,20 @@
"use client"; "use client";
import type { ReactElement, ReactNode } from "react"; import type { 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 withOptionalTourTarget = (id: string | undefined, children: ReactElement) => const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
id ? <TourTarget id={id}>{children}</TourTarget> : children; if (!id) return <>{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}/`);
@@ -42,7 +43,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
active={isClient && pathMatches(pathname, props.href)} active={isClient && pathMatches(pathname, props.href)}
/> />
); );
return withOptionalTourTarget(tourId, link); return <TourTarget id={tourId}>{link}</TourTarget>;
}; };
const NavLinkWithItems = (props: NavigationLinkWithItems) => { const NavLinkWithItems = (props: NavigationLinkWithItems) => {
@@ -55,7 +56,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
))} ))}
</NavLink> </NavLink>
); );
return withOptionalTourTarget(props["data-onboarding-tour-id"], nav); return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
}; };
interface CommonNavigationLinkProps { interface CommonNavigationLinkProps {
@@ -1,11 +0,0 @@
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, useState } from "react"; import { useMemo } 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,7 +10,6 @@ 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";
@@ -18,11 +17,21 @@ 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 [started, setStarted] = useState(true); 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 started = tourStatus !== undefined && !tourStatus.completedBoardTour;
const handleEnd = () => { const handleEnd = () => {
setStarted(false); utils.user.getTourStatus.setData(undefined, {
completedManageTour: tourStatus?.completedManageTour ?? false,
completedBoardTour: true,
});
completeTour({ tour: "board" }); completeTour({ tour: "board" });
}; };
@@ -92,10 +101,8 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
}, [hasChangeAccess, t]); }, [hasChangeAccess, t]);
return ( return (
<TourTargetsProvider enabled={started}> <TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}> {children}
{children} </TourShell>
</TourShell>
</TourTargetsProvider>
); );
}; };
@@ -1,12 +0,0 @@
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, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, 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,7 +17,6 @@ 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";
@@ -47,7 +46,13 @@ 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 { mutate: completeTour } = clientApi.user.completeTour.useMutation(); const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
onSuccess() {
void utils.user.getTourStatus.invalidate();
},
});
const isMobile = useMediaQuery("(max-width: 48em)"); const isMobile = useMediaQuery("(max-width: 48em)");
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@@ -57,13 +62,14 @@ 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 && !isMobile) { if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
setTourActive(true); setTourActive(true);
} }
}, [isManageHome, isMobile, tourDismissed]); }, [isManageHome, tourStatus, isMobile, tourDismissed]);
useEffect(() => { useEffect(() => {
if (!isManageSection) { if (!isManageSection) {
@@ -71,12 +77,25 @@ 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]); }, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
const steps = useMemo(() => { const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [ const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
@@ -188,16 +207,14 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
}, [isAdmin, t]); }, [isAdmin, t]);
return ( return (
<TourTargetsProvider enabled={tourActive}> <TourShell
<TourShell steps={steps}
steps={steps} started={tourActive}
started={tourActive} onEnd={handleEnd}
onEnd={handleEnd} stepRoutes={stepRoutes}
stepRoutes={stepRoutes} position={{ base: "bottom", sm: "right" }}
position={{ base: "bottom", sm: "right" }} >
> {children}
{children} </TourShell>
</TourShell>
</TourTargetsProvider>
); );
}; };
+66 -19
View File
@@ -1,13 +1,24 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback } from "react"; import { useCallback, useEffect } from "react";
import { Menu } from "@mantine/core"; import { useRouter } from "next/navigation";
import { IconBrandDocker, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react"; import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import {
IconBrandDocker,
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import 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 { useModalAction } from "@homarr/modals"; import { hotkeys } from "@homarr/definitions";
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";
@@ -19,38 +30,42 @@ import { AvailableUpdatesMenuItem } from "./layout/header/update";
interface UserAvatarMenuProps { interface UserAvatarMenuProps {
children: ReactNode; children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"]; availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
isDockerEnabled?: boolean; isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
} }
export const UserAvatarMenu = ({ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
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,
}); });
window.location.assign(redirectUrl); openModal({
}, [logoutUrl]); onTimeout: () => {
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 opened={opened} onChange={onOpenChange}> <Menu width={300} withinPortal keepMounted>
<Menu.Dropdown> <Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} /> <AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
<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>
@@ -98,3 +113,35 @@ export const UserAvatarMenu = ({
</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,
});
-4
View File
@@ -1,4 +0,0 @@
// 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();
-125
View File
@@ -1,125 +0,0 @@
// @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");
});
});
+4 -36
View File
@@ -1,38 +1,6 @@
export async function register() { export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return; if (process.env.NEXT_RUNTIME === "nodejs") {
if (process.env.NODE_ENV !== "production") return; await import("@homarr/tasks");
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()]);
} }
-122
View File
@@ -1,122 +0,0 @@
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);
});
});
+7 -40
View File
@@ -1,51 +1,21 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getDefaultLocaleForProxyAsync, getOnboardingStepForProxyAsync } from "@homarr/db/proxy-reader"; import { api } from "@homarr/api/server";
import { localeCookieKey } from "@homarr/definitions/cookie"; import { localeCookieKey } from "@homarr/definitions";
import type { SupportedLanguage } from "@homarr/translation/languages"; import type { SupportedLanguage } from "@homarr/translation";
import { supportedLanguages } from "@homarr/translation/languages"; import { supportedLanguages } from "@homarr/translation";
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 getOnboardingStepDedupedAsync(); const currentOnboardingStep = await api.onboard.currentStep();
if (currentOnboardingStep !== "finish") { if (currentOnboardingStep.current !== "finish") {
return NextResponse.redirect(new URL("/init", request.url)); return NextResponse.redirect(new URL("/init", request.url));
} }
@@ -56,10 +26,7 @@ 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)) {
const configuredLocale = await getDefaultLocaleDedupedAsync(); defaultLocale = await api.serverSettings.getCulture().then((culture) => culture.defaultLocale);
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
+4 -3
View File
@@ -1,7 +1,8 @@
import { cache } from "react"; import { cache } from "react";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; import { db } from "@homarr/db";
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";
@@ -12,6 +13,6 @@ export const getCurrentColorSchemeAsync = cache(async () => {
return cookieValue as ColorScheme; return cookieValue as ColorScheme;
} }
const serverSettings = await getRscServerSettingsAsync(); const appearanceSettings = await getServerSettingByKeyAsync(db, "appearance");
return serverSettings.appearance.defaultColorScheme; return appearanceSettings.defaultColorScheme;
}); });
+1 -1
View File
@@ -5,7 +5,7 @@ import { jobGroup } from "@homarr/cron-jobs";
import { onStartAsync } from "./on-start"; import { onStartAsync } from "./on-start";
export const startupPromise = (async () => { void (async () => {
await onStartAsync(); await onStartAsync();
await jobGroup.initializeAsync(); await jobGroup.initializeAsync();
await jobGroup.startAllAsync(); await jobGroup.startAllAsync();
+2
View File
@@ -1,5 +1,7 @@
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();
} }
@@ -0,0 +1,14 @@
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 }));
}
}
+1 -19
View File
@@ -12,25 +12,6 @@ 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,
@@ -76,6 +57,7 @@ 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");
+1 -2
View File
@@ -45,9 +45,8 @@ 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 loginNavigation; await page.waitForURL(baseUrl, { timeout: 15_000 });
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 });
+18 -26
View File
@@ -8,17 +8,13 @@ describe("Health checks", () => {
// Arrange // Arrange
const homarrContainer = await createHomarrContainer().start(); const homarrContainer = await createHomarrContainer().start();
try { // Act
// Act const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`); const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
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 () => {
@@ -33,22 +29,18 @@ describe("Health checks", () => {
}, },
}).start(); }).start();
try { // Act
// Act const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`); const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
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);
}); });
+4 -8
View File
@@ -7,14 +7,10 @@ describe("Home", () => {
// Arrange // Arrange
const homarrContainer = await createHomarrContainer().start(); const homarrContainer = await createHomarrContainer().start();
try { // Act
// Act const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
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);
}); });
-110
View File
@@ -1,110 +0,0 @@
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);
});
+34 -37
View File
@@ -37,48 +37,45 @@ describe("LLDAP authorization", () => {
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
try { const onboardingActions = new OnboardingActions(page, db);
const onboardingActions = new OnboardingActions(page, db); await onboardingActions.skipOnboardingAsync({
await onboardingActions.skipOnboardingAsync({ group: defaultCredentials.group,
group: defaultCredentials.group, });
});
// Act // Act
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
await page.goto(`${baseUrl}/auth/login`); await page.getByLabel("Username").fill(defaultCredentials.username);
await page.getByLabel("Username").fill(defaultCredentials.username); await page.locator("#password").fill(defaultCredentials.password);
await page.locator("#password").fill(defaultCredentials.password); await page.locator("css=button[type='submit']").click();
await page.locator("css=button[type='submit']").click();
// Assert // Assert
await page.waitForURL(baseUrl, { timeout: 60_000 }); await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
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); });
const user = users[0]; expect(users).toHaveLength(1);
if (!user) { const user = users[0]!;
throw new Error("Expected LDAP login to create one user"); expect(user).toEqual(
} expect.objectContaining({
expect(user).toEqual( name: defaultCredentials.username,
expect.objectContaining({ email: defaultCredentials.email,
name: defaultCredentials.username, provider: "ldap",
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 {
await Promise.all([browser.close(), homarrContainer.stop(), lldapContainer.stop()]); // Cleanup
} await browser.close();
await homarrContainer.stop();
await lldapContainer.stop();
}, 120_000); }, 120_000);
}); });
+34 -34
View File
@@ -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);
try { // Act
// Act await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`); await actions.startOnboardingAsync("scratch");
await actions.startOnboardingAsync("scratch"); await actions.processUserStepAsync({
await actions.processUserStepAsync({ username: "admin",
username: "admin", password: "Comp(exP4sswOrd",
password: "Comp(exP4sswOrd", confirmPassword: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd", });
}); await actions.processSettingsStepAsync();
await actions.processSettingsStepAsync(); await actions.processIntegrationsStepAsync();
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 {
await Promise.all([browser.close(), homarrContainer.stop()]); // Cleanup
} 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);
try { // Act
// Act await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`); await actions.startOnboardingAsync("scratch");
await actions.startOnboardingAsync("scratch"); await actions.processExternalGroupStepAsync({
await actions.processExternalGroupStepAsync({ name: externalGroupName,
name: externalGroupName, });
}); await actions.processSettingsStepAsync();
await actions.processSettingsStepAsync(); await actions.processIntegrationsStepAsync();
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 {
await Promise.all([browser.close(), homarrContainer.stop()]); // Cleanup
} 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: 30_000 }); await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
} }
} }
+1 -9
View File
@@ -4,11 +4,6 @@
"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",
@@ -26,7 +21,6 @@
"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",
@@ -40,7 +34,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": "pnpm -F nextjs start", "start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"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",
@@ -51,7 +45,6 @@
"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:",
@@ -72,7 +65,6 @@
"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:",
-3
View File
@@ -9,10 +9,7 @@
"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",
-16
View File
@@ -1,16 +0,0 @@
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();
});
+16 -46
View File
@@ -1,54 +1,22 @@
import type { QueryKey } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query";
// RSC-prefetched data must survive the hydration pass without an immediate export const queryCacheDefaultStaleTimeMs = 0;
// 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 * 5; export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5; export const queryCacheMaxValueBytes = 1024 * 1024;
export const queryCacheStoragePrefix = "homarr-widget-query";
export const queryCacheBuster = "v2-superjson";
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]); let activeQueryCacheBoardId: string | null = null;
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
export const isWidgetDataTrpcPath = (path: string) => { export const setActiveQueryCacheBoardId = (boardId: string | null) => {
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path); activeQueryCacheBoardId = boardId;
return widgetDataQueryPaths.has(path);
}; };
// These supporting queries can be numerous on a dashboard. Give them a export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
// predictable refresh policy instead of refetching all of them after every tab
// focus/reconnect event. Metadata can refresh after a bounded stale window; const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
// Docker state remains live on a single shared timer. const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
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];
@@ -58,9 +26,11 @@ const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
return parts.length > 0 ? parts : null; return parts.length > 0 ? parts : null;
}; };
export const isWidgetDataQueryKey = (queryKey: QueryKey) => { export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey); const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false; if (!path) return false;
return isWidgetDataTrpcPath(path.join(".")); if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
return persistableQueryPaths.has(path.join("."));
}; };
+1
View File
@@ -24,6 +24,7 @@ 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)),
+7 -10
View File
@@ -1757,12 +1757,9 @@ 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 groupPermissionWhere = userId const groupsOfCurrentUser = await db.query.groupMembers.findMany({
? inArray( where: eq(groupMembers.userId, userId ?? ""),
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: {
@@ -1785,8 +1782,8 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
items: { items: {
with: { with: {
integrations: { integrations: {
columns: { with: {
integrationId: true, integration: true,
}, },
}, },
layouts: true, layouts: true,
@@ -1800,7 +1797,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
}, },
}, },
groupPermissions: { groupPermissions: {
where: groupPermissionWhere, where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
}, },
}, },
}); });
@@ -1848,7 +1845,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.integrationId), integrationIds: itemIntegrations.map((item) => item.integration.id),
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,12 +19,9 @@ export const throwIfActionForbiddenAsync = async (
permission: BoardPermission, permission: BoardPermission,
) => { ) => {
const { db, session } = ctx; const { db, session } = ctx;
const groupPermissionWhere = session const groupsOfCurrentUser = await db.query.groupMembers.findMany({
? inArray( where: eq(groupMembers.userId, session?.user.id ?? ""),
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: {
@@ -37,7 +34,7 @@ export const throwIfActionForbiddenAsync = async (
where: eq(boardUserPermissions.userId, session?.user.id ?? ""), where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
}, },
groupPermissions: { groupPermissions: {
where: groupPermissionWhere, where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
}, },
}, },
}); });
+42
View File
@@ -0,0 +1,42 @@
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);
}),
});
@@ -1,58 +0,0 @@
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([]);
});
});
+2 -2
View File
@@ -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 read the cached update check", { cause: error })); logger.error(new Error("Failed to get available updates", { cause: error }));
return []; // An empty list hides the indicator without violating TanStack Query's data contract. return undefined; // We return undefined to not show the indicator in the UI
} }
}), }),
}); });
@@ -2,7 +2,6 @@ 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";
@@ -21,8 +20,6 @@ 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,6 +1,5 @@
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";
@@ -12,8 +11,6 @@ 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);
}), }),
-16
View File
@@ -1,16 +0,0 @@
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,
});
});
@@ -1,8 +0,0 @@
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));
+12 -3
View File
@@ -1,16 +1,25 @@
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 } from "@homarr/api"; import { appRouter, createCaller, createTRPCContext } 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 = () => createRscTrpcContext(); const createContext = cache(async () => {
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);
+119 -54
View File
@@ -1,20 +1,65 @@
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 {
dashboardSupportingQueryPolicies, isPersistableWidgetQueryKey,
isWidgetDataQueryKey,
isWidgetDataTrpcPath,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs, queryCacheDefaultStaleTimeMs,
queryCacheMetadataStaleTimeMs, queryCacheMaxValueBytes,
} 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();
}); });
describe("isWidgetDataQueryKey", () => { const createSession = (userId = createId()): Session => ({
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],
@@ -27,22 +72,77 @@ describe("isWidgetDataQueryKey", () => {
[[["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 widget data queries for %j", (queryKey, expected) => { ])("matches persist filter for %j", (queryKey, expected) => {
expect(isWidgetDataQueryKey(queryKey)).toBe(expected); expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
}); });
}); });
describe("isWidgetDataTrpcPath", () => { describe("queryCacheRouter", () => {
test.each([ test("stores and removes data for a board viewer", async () => {
["widget.calendar.findAllEvents", true], const db = createDb();
["app.byIds", true], const session = createSession();
["integration.byIds", true], await insertUserAsync(db, session.user.id);
["docker.getContainers", true], await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
["widget.app.ping", false],
["widget.beszel.getSystemStats", false], const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
["board.getBoardByName", false],
])("classifies dashboard data path %s", (path, expected) => { await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).resolves.toEqual({
expect(isWidgetDataTrpcPath(path)).toBe(expected); stored: true,
});
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,
});
}); });
}); });
@@ -76,38 +176,3 @@ 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,
},
]);
});
});
-24
View File
@@ -1,24 +0,0 @@
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),
}),
);
+16 -13
View File
@@ -3,18 +3,24 @@ 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 } from "@homarr/db"; import { eq, inArray } 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)
.innerJoin(groupMembers, eq(groupPermissions.groupId, groupMembers.groupId)) .where(inArray(groupPermissions.groupId, groupIds));
.where(eq(groupMembers.userId, userId));
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission); const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
return getPermissionsWithChildren(permissionKeys); return getPermissionsWithChildren(permissionKeys);
@@ -37,15 +43,12 @@ 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, permissions] = await Promise.all([ const additionalProperties = await db.query.users.findFirst({
db.query.users.findFirst({ where: eq(users.id, user.id),
where: eq(users.id, user.id), columns: {
columns: { colorScheme: true,
colorScheme: true, },
}, });
}),
getCurrentUserPermissionsAsync(db, user.id),
]);
return { return {
...session, ...session,
@@ -54,7 +57,7 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
...additionalProperties, ...additionalProperties,
id: user.id, id: user.id,
name: user.name, name: user.name,
permissions, permissions: await getCurrentUserPermissionsAsync(db, user.id),
}, },
}; };
}; };
@@ -3,9 +3,14 @@
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 187 files, more files were not shown because too many files have changed in this diff. Show more