Compare commits

..
150 changed files with 24544 additions and 28384 deletions

No files matched your search

+1 -1
View File
@@ -37,4 +37,4 @@ Orient from the current checkout before editing. Treat package manifests, export
- Cron jobs: `packages/cron-jobs/src/jobs/`
- App routes: `apps/nextjs/src/app/[locale]/`
Invoke `documentation-sync` when hidden capabilities or information not evident to an advanced user change, or existing guidance becomes inaccurate; follow the Documentation Sync criteria in `AGENTS.md`. When exposing tRPC through MCP, invoke `mcp-integration`.
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
+5 -9
View File
@@ -1,17 +1,13 @@
---
name: documentation-sync
description: Write concise Homarr docs for advanced users, covering only hidden capabilities and information not evident from the UI. Use when that information changes or existing guidance becomes inaccurate; skip obvious controls and routine fixes.
description: Keep Homarr's Docusaurus documentation aligned with user-facing code. Use when adding or changing integrations, widgets, APIs, environment variables, CLI commands, authentication, permissions, cron jobs, settings, or UI behavior that users must understand.
---
# Documentation Sync
Apply the audience and scope rules in `AGENTS.md` first. Before adding a passage, identify what an advanced user could not learn directly from the interface. If there is no such information, omit it.
Update `apps/docs/` in the same change as user-facing behavior. Inspect adjacent pages and the docs types before copying a pattern; the source and docs evolve together.
For hidden capabilities such as the advanced widget feature, explain discovery, activation, and non-obvious behavior. For visible standard controls such as Delete, omit narration of what the label already conveys. Use the shortest explanation that preserves necessary technical detail; remove redundant prose in the passage being edited.
When an update is warranted, inspect adjacent pages for structure and types, not as a verbosity target. The mappings below locate needed information; they do not require a page for every feature or code change.
## Locate a needed update
## Map the change
| Code change | Documentation target |
| ----------------------------------- | -------------------------------------------------------------------- |
@@ -44,11 +40,11 @@ For a widget:
- Add or update `apps/docs/docs/widgets/<slug>/index.ts` with the local `WidgetDefinition` pattern.
- Add or update `index.mdx` with the established `WidgetHeader`, `WidgetConfig`, and `AddingWidget` components as applicable.
- Explain only non-obvious option semantics, integration prerequisites, permission constraints, or behavior that the interface does not make clear.
- Document options, required integrations, permissions, empty states, and changed behavior that affect setup or use.
## Completion criteria
1. Every passage adds information an advanced user cannot reasonably infer from the interface. Remove obvious, repetitive, or unnecessary prose.
1. Describe the shipped behavior, not an implementation plan.
2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code.
3. Update every affected page and remove superseded guidance.
4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
+2 -3
View File
@@ -16,10 +16,9 @@ LOG_LEVEL='info'
# This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3'
# Set an absolute writable SQLite path so migrations and the app use the same file.
# Their working directories differ; do not use a relative path here.
# Safe local source-development path. SQLite files are ignored by Git.
# Container images use /appdata/db/db.sqlite by default instead.
DB_URL=
DB_URL='./db.sqlite'
# Those are the two ways to use the mysql2 driver:
# 1. Using the URL format:
-13
View File
@@ -25,18 +25,6 @@ body:
attributes:
label: Additional information
description: Add any other information related to the bug here, screenshots if applicable.
- type: input
id: exactBuild
attributes:
label: Exact build or image
description: For preview or development builds, include the commit SHA or image digest. A moving tag such as v2 is not enough to identify the build.
placeholder: "Commit SHA, image digest, or release version"
- type: input
id: lastWorkingVersion
attributes:
label: Last working version
description: If this worked before, include the last version or build where you tried the same action. Leave blank if unknown.
placeholder: "Version or commit SHA"
- type: dropdown
id: version
attributes:
@@ -45,7 +33,6 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.77.0
- 1.76.2
- 1.76.1
- 1.76.0
+57 -10
View File
@@ -97,16 +97,17 @@ jobs:
- name: Validate Custom Widget bundle budget
run: |
pnpm turbo run build --filter=@homarr/nextjs
pnpm --filter nextjs build
pnpm check:custom-widget-bundle
- name: Run unit tests
run: pnpm test
container:
name: Container build
# Same-repository previews already build amd64; only build a fallback when no digest is available.
name: Container and E2E
needs: [fast-gate, preview-amd64]
if: |
!cancelled() && needs.fast-gate.result == 'success' &&
needs.preview-amd64.result != 'failure' && needs.preview-amd64.outputs.digest == '' &&
(
github.event_name == 'merge_group' ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -114,9 +115,10 @@ jobs:
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
)
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
@@ -124,10 +126,23 @@ jobs:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
if: "!env.ACT"
uses: docker/setup-buildx-action@v4
- name: Reuse amd64 preview image
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
env:
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
- name: Build container once
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/build-push-action@v7
@@ -137,7 +152,9 @@ jobs:
platforms: linux/amd64
load: true
push: false
tags: homarr-ci
tags: |
homarr-ci
homarr-e2e
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
@@ -165,7 +182,37 @@ jobs:
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci "${secret_args[@]}" .
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run E2E tests
env:
NODE_ENV: development
CI: "true"
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
preview-amd64:
name: Build amd64 preview image
@@ -273,10 +320,10 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [fast-gate, preview-amd64, preview-arm64]
needs: [container, preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.fast-gate.result == 'success' &&
needs.container.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
+8 -19
View File
@@ -16,10 +16,10 @@ permissions:
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -37,22 +37,11 @@ jobs:
branch: next
- name: Create pull request
id: create-pull-request
run: |
pr_url="$(gh pr list --base beta --head next --state open --json url --jq '.[0].url // empty')"
if [[ -z "$pr_url" ]]; then
pr_url="$(gh pr create \
--title "chore(release): release ${{ steps.semver.outputs.next }}-beta" \
--body "Detected change to be ${{ steps.semver.outputs.bump }}<br/>Bump version from ${{ steps.semver.outputs.current }} to ${{ steps.semver.outputs.next }}-beta" \
--base beta \
--head next \
--label automerge)"
fi
echo "url=$pr_url" >> "$GITHUB_OUTPUT"
echo "pr_number=${pr_url##*/}" >> "$GITHUB_OUTPUT"
run: 'gh pr create --title "chore(release): release ${{ steps.semver.outputs.next }}-beta" --body "Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}-beta``" --base beta --head next --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -69,7 +58,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review "${{ steps.create-pull-request.outputs.url }}" --approve --body "Automatically approved by GitHub Action"
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: actions/create-github-app-token@v3
@@ -93,21 +82,21 @@ jobs:
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && inputs.send-notifications }}
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && inputs.send-notifications }}
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Discord Fail Notification
if: ${{ failure() && inputs.send-notifications }}
if: failure() && github.event.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
+12 -29
View File
@@ -2,36 +2,8 @@ name: Workshop
on:
pull_request:
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
push:
branches: [dev, v2, release/v2, main]
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
permissions:
contents: read
@@ -43,10 +15,21 @@ concurrency:
jobs:
validate:
runs-on: ubuntu-24.04
timeout-minutes: 5
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24.18.0
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @homarr/workshop typecheck
- run: pnpm --filter @homarr/docs typecheck
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
- run: pnpm test:workshop
- run: pnpm test:workshop-image
publish-amd64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
+3 -13
View File
@@ -59,13 +59,7 @@ homarr/
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
Write for smart, curious, advanced users. Documentation should contain only information they cannot reasonably discover or understand directly from the interface: hidden capabilities, non-obvious behavior, prerequisites, constraints, configuration contracts, and migration requirements.
Keep docs concise. Every paragraph must add knowledge beyond visible labels and standard UI conventions. Explain how to discover and use a hidden advanced widget feature; omit explanations that the Delete button deletes an item in edit mode. Avoid UI walkthroughs, exhaustive control descriptions, and repetition. When editing a passage, remove obvious or redundant prose rather than preserving verbosity as a precedent.
Update docs only when a change introduces or alters that non-obvious information, or makes existing guidance inaccurate. Routine fixes and self-explanatory UI changes need no docs update. A new feature alone does not require a page; apply the same reader-value test.
When documentation is needed, use these locations:
When modifying user-facing code, you MUST also update the corresponding documentation:
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
@@ -92,7 +86,7 @@ When documentation is needed, use these locations:
- Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Mantine: use the `mantine` MCP server in `.mcp.json` for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox`, the polymorphic `component` prop, `@mantine/hooks`) and check `packages/ui/` for existing conventions first.
- Mantine: use the project-scoped MCP server in `.mcp.json` for current v9 APIs, and check `packages/ui/` before creating a new primitive.
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
@@ -100,15 +94,11 @@ When documentation is needed, use these locations:
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
## MCP servers
`.mcp.json` declares project-scoped MCP servers. Keep only keyless definitions there; credentials belong in local dotfiles. See [Mantine LLM tooling](https://mantine.dev/guides/llms/) for client-specific setup.
## Agent Skills
Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`.
- `codebase-context` — architecture, package boundaries, and shared utilities
- `documentation-sync` — documentation for changes users need explained
- `documentation-sync` — required user-facing documentation updates
- `mcp-integration` — safe tRPC-to-MCP exposure
- `homarr-custom-widget` — safe Custom JSX v2 authoring
-4
View File
@@ -1,7 +1,3 @@
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
+14 -6
View File
@@ -15,13 +15,19 @@ Many of you used the v2 demo when it was still rough, found bugs and came back t
We are also planning a small batch of **free, limited-edition Homarr swag** for Workshop contributors and a few randomly picked community members. It is not for sale and it is not for profit. No Homarr merch empire, sorry.
Homarr 2.0 builds on the dashboard you already use. Here is what changed.
Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is what actually changed.
![A populated Homarr 2.0 dashboard with compact widgets, an Assistant, and an application rail](./img/homarr-v2-dashboard.webp)
<!-- truncate -->
Update your Homarr image as usual. Homarr runs database migrations automatically when the updated container starts; no manual database migration or separate v2 upgrade procedure is needed. Workshop uses the production service by default, with no preview URL configuration required.
:::warning Back up before upgrading
Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2) and create a
[Homarr backup](/docs/management/backup) first. Do not test a prerelease image on your only production volume.
Backups are boring. Restores are worse.
:::
## Custom Widgets v2 and Workshop
@@ -144,8 +150,9 @@ Groups use a new Viewer, Editor and Admin matrix. The API, Assistant and MCP fol
Board startup work runs in parallel, heavy screens load when opened, and identical integration requests share work instead of doing it twice.
## Compatibility notes
## Before upgrading
- Keep a backup and use the final release image, not a prerelease tag.
- Legacy Custom JSX definitions enter a migration state. Homarr keeps encrypted credentials and an archived copy until the v2 definition validates.
- Mobile layouts place rail content in the main flow instead of displaying rails.
- Custom Widgets remain Beta.
@@ -153,7 +160,8 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
## Links
- Try the [live demo](https://demo.homarr.dev/).
- See [installation and update instructions](/docs/getting-started/installation/docker).
- Browse [Workshop](https://homarr.dev/workshop).
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
- Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2).
- Follow the [v2 release pull request](https://github.com/homarr-labs/homarr/pull/6545) and [public beta thread](https://github.com/homarr-labs/homarr/issues/6600).
- Compare [v1.76.2 with the release/v2 branch](https://github.com/homarr-labs/homarr/compare/v1.76.2...release/v2).
- Read the guides for [Boards](/docs/management/boards), [Custom Widgets](/docs/management/custom-widgets), [Assistant](/docs/management/assistant), [MCP](/docs/management/mcp) and [Docker labels](/docs/integrations/docker-labels).
@@ -90,7 +90,7 @@ homarr run dev
homarr run --detach dev
homarr run --pr 6441
homarr run --pr 6441 --demo
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://workshop.example.com/workshop
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
```
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
@@ -31,13 +31,9 @@ pnpm install --frozen-lockfile
### Run the Homarr application
1. Copy `.env.example` to `.env`.
2. Run `openssl rand -hex 32` twice. Set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
values in `.env`; the example leaves both empty. Keep this file private and preserve `SECRET_ENCRYPTION_KEY`
when reusing a database.
3. Set `DB_URL` to an absolute writable SQLite file path. Migration and application commands run from different
workspace directories, so a relative path can point them at different databases.
4. Keep Redis and the optional local databases running in one terminal.
5. Apply migrations and start Next.js in another terminal.
2. Set `DB_URL` to an absolute writable SQLite file path.
3. Keep Redis and the optional local databases running in one terminal.
4. Apply migrations and start Next.js in another terminal.
```sh
cp .env.example .env
@@ -61,20 +57,6 @@ Homarr becomes the instance administrator.
Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
default data; use `pnpm db:seed` to seed it explicitly later.
`pnpm dev` starts only Next.js. When your work needs scheduled tasks or live subscriptions, run the relevant service
in a separate terminal alongside it:
```sh
pnpm --filter @homarr/tasks dev
```
```sh
pnpm --filter @homarr/websocket dev
```
The WebSocket service listens on port 3001. Production startup embeds both services, so these extra commands are
only needed for development.
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
[Workshop development guide](./workshop).
@@ -98,27 +80,5 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
- `pnpm typecheck` — typecheck all workspaces.
- `pnpm test` — unit and contract suites.
- `pnpm build` — production monorepo build.
- `docker build -t homarr:local .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:local` — run it.
## CI and focused validation
The main CI workflow runs lint, typechecking, affected workspace builds, workspace configuration checks, OpenAPI validation, and Custom Widget architecture and bundle checks. Unit and E2E suites do not run in CI. Preview images publish after the Fast gate and both architecture builds succeed; browser tests do not block publication. The container job builds an amd64 image when no preview image was built.
Workshop CI runs only when its workflow or Docker build inputs change. It validates Compose configuration and publishes production images on its configured release branches. Its Docker integration and image tests are manual; workspace typechecks run in the main Fast gate.
Choose the smallest relevant suite when changing behavior:
| Command | Scope | Requirements |
| ------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| `pnpm test <file>` | Focused unit or contract tests | Workspace dependencies |
| `pnpm test` | All unit and contract tests, without Docker integration suites | Workspace dependencies |
| `pnpm test:coverage` | Unit and contract tests with coverage | Workspace dependencies |
| `pnpm test:integration <file>` | Docker-backed service and database compatibility tests | Docker |
| `pnpm test:e2e <file>` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests |
| `pnpm test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites |
| `pnpm test:workshop` / `pnpm test:workshop-image` | Workshop integration / production-image validation | Docker |
Omit `<file>` to run all tests in that command's scope. E2E and screenshot commands use a local image tagged `homarr-e2e`, or an image selected with `HOMARR_E2E_IMAGE`. The default commands run once without automatic retries; coverage is opt-in. The screenshot generator is excluded from both unit and E2E commands.
Keep behavioral coverage for authorization, migrations, persistence, and widget execution. Avoid tests that only search source code for particular component names, JSX, CSS, or documentation wording.
- `docker build -t homarr .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
@@ -13,8 +13,7 @@ You can change the user and group by using the `PUID` and `PGID` environment var
Important to note is, that you'll also need to change the permissions for the mounted directories accordingly.
:::info
At startup, Homarr updates ownership of its data directories and runtime caches for the selected user and group.
Startup time depends on the amount of persistent data; application code and dependencies do not need ownership changes.
When starting up an updated version of the container it will have about **10 seconds longer to start** as it has to change the permissions of the app directories
:::
## Without mounted Docker socket
@@ -24,10 +24,6 @@ see [Managing integrations](/docs/management/integrations#configuration).
### Update
Update the image and recreate the container with the same configuration. Homarr runs database migrations automatically
at startup, including when moving from v1 to v2; no separate upgrade procedure is needed. Keep the `/appdata` mount and
`SECRET_ENCRYPTION_KEY` unchanged. Workshop connects to the production service by default.
```sh
docker compose pull
docker compose up -d
@@ -21,8 +21,8 @@ This method is only useful, when you want to develop on Homarr or extend it with
## Prerequisites
- [Node.js](https://nodejs.org/en/download/) 24.18 or newer
- [pnpm](https://pnpm.io/installation) at the version pinned in the repository's `package.json`
- [Node.js](https://nodejs.org/en/download/)
- [pnpm](https://pnpm.io/installation)
- [Redis](https://redis.io/download)
## Steps
@@ -38,9 +38,9 @@ This method is only useful, when you want to develop on Homarr or extend it with
Install all dependencies using <code>pnpm install</code>
</li>
<li>
Copy `.env.example` to `.env` and set `DB_URL` to an absolute writable path, such as
`DB_URL='/home/username/homarr/db.sqlite'`. Migration and application commands run from different workspace
directories, so a relative path can point them at different databases.
Copy the `.env.example` file to `.env`. Its `DB_URL='./db.sqlite'` value is ready for local use and stores the
database in the cloned repository. Replace it with an absolute writable path such as
`DB_URL='/home/username/homarr/db.sqlite'` if you want to store the database elsewhere.
</li>
<li>
Run `openssl rand -hex 32` twice and set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
@@ -0,0 +1,74 @@
---
title: Upgrade from v1 to v2
sidebar_position: 2
tags:
- Getting started
- Upgrade
- Migration
- Backup
---
Homarr v2 upgrades the existing v1 database in place. Keep the same data storage, database configuration, and
`SECRET_ENCRYPTION_KEY`; only change the Homarr image or package version for the first start.
:::danger Back up before starting v2
In Homarr v1, go to **Management → Tools → Backup**, export a backup, and keep the downloaded file somewhere safe
before starting v2. See [Backup and restore](/docs/management/backup) for details.
:::
## Upgrade
1. Pin the Homarr image to `ghcr.io/homarr-labs/homarr:v2.0.0` for the first v2 start.
2. Keep the existing `/appdata` mount, database variables, Redis configuration, and `SECRET_ENCRYPTION_KEY` unchanged.
3. Start one Homarr replica and follow its logs. Startup must reach `Migration complete`; Homarr aborts startup when a
database migration fails.
4. Open Homarr and sign in. Confirm **Management → About** reports v2 before starting additional replicas.
5. After validation, keep the version pinned or return to your normal stable release policy.
For Docker Compose, the change is limited to the image tag before the usual update:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr:v2.0.0
```
```sh
docker compose pull
docker compose up -d
docker compose logs --follow homarr
```
Do not add `-v` when stopping the Compose deployment; that can remove named volumes.
## What v2 migrates
The startup migration:
- creates the Custom Widget v2, Assistant, header preference, and branding storage;
- adds Base/Mobile layout roles and left/right board gutters;
- converts legacy Categories and dynamic sections into Containers and rails;
- preserves legacy Custom Widget definitions, encrypted secrets, and board references for explicit migration.
Legacy Custom Widgets are not silently rewritten. In **Management → Custom Widgets**, entries marked
**Migration required** provide a credential-free migration prompt. Review and test the resulting v2 definition before
removing the archived v1 version. See [Custom Widgets: Import and Workshop](/docs/management/custom-widgets#import-and-workshop).
## Verify the upgraded instance
- Sign in with each enabled authentication method and verify administrator access.
- Open every important board on desktop and mobile. Check Containers, former Categories, rails, item placement, and
home-board assignments.
- Test at least one read and one permitted action for each critical integration. Recheck integration and board access
for representative Viewer, Editor, and Admin groups.
- Check Docker or Podman endpoints, search engines, uploaded media, trusted certificates, scheduled tasks, WebSockets,
and external Redis when used.
- Review every legacy Custom Widget and migrate it only after its v2 preview and requests work.
- Restart Homarr once more and confirm the logs contain no migration, database, Redis, authentication, or task errors.
## Roll back
Use the backup exported before the upgrade if you need to restore your v1 installation. Do not reuse a database that
v2 has already migrated with v1.
@@ -28,8 +28,6 @@ import { smartHomeExecuteAutomationWidget } from "@site/docs/widgets/smart-home-
]}
/>
Entity toggles and automation triggers send JSON requests to Home Assistant. A rejected action is shown as a failure.
### Adding the integration
<AddingIntegration />
@@ -1,55 +0,0 @@
---
title: "llama.cpp"
description: "Connect Homarr to a llama.cpp llama-server to monitor your local LLM: health, loaded model, generation speed and request status."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { llamacppIntegration } from ".";
import { llamacppWidget } from "@site/docs/widgets/llama-cpp";
<IntegrationHeader integration={llamacppIntegration} categories={["System Monitoring"]} />
The llama.cpp integration lets Homarr connect to a [llama-server](https://github.com/ggml-org/llama.cpp) instance so you can see the health of your local LLM, which model is loaded, and how fast it is generating — all from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: llamacppWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your `llama-server`, for example `http://192.168.1.50:8080`. Homarr calls the `/health`, `/v1/models`, `/metrics` and `/slots` endpoints under that URL automatically.
:::
:::tip Metrics
Generation speed and token counters are read from the Prometheus endpoint at `/metrics`. Start `llama-server` with the `--metrics` flag so these values are available:
```shell
llama-server --model /path/to/model.gguf --metrics
```
Without `--metrics` the widget still shows health, model information, and active-request speed from `/slots`; metrics-backed counters and aggregate speed stay empty.
:::
:::info Context and per-request speed
Context (KV cache) usage and the in-flight request data are read from the `/slots` endpoint, which is available on every recent llama-server build and requires no extra flags. The per-request generation speed shown while a request is running is derived by tracking the request's decoded token count across polls, so it reflects the average speed of the request that is currently being generated.
:::
### Secrets
llama-server does not require authentication by default. The integration only needs the URL of your server.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
]}
/>
@@ -1,9 +0,0 @@
import { IntegrationDefinition } from "@site/src/types";
export const llamacppIntegration: IntegrationDefinition = {
name: "llama.cpp",
description:
"Connect to a local llama.cpp llama-server to show model, throughput and request status on your dashboard.",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
path: "../../integrations/llama-cpp",
};
@@ -30,8 +30,6 @@ import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
]}
/>
Disk temperatures are matched to filesystem partitions, including device names without the `/dev/` prefix.
### Adding the integration
<AddingIntegration />
@@ -19,9 +19,6 @@ import Alert from "@theme/Admonition";
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
Disk space uses each pool's root dataset to account for RAIDZ parity and dataset limits. If dataset space is unavailable,
Homarr falls back to physical pool space, which reads higher on RAIDZ, and keeps reporting pool health.
### Adding the integration
<AddingIntegration />
@@ -23,8 +23,6 @@ import imgSelectApikeyPreset from "./imgs/unraid-select-api-key-preset.png";
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
CPU usage is averaged across logical processors, including SMT threads.
### Adding the integration
<AddingIntegration />
-6
View File
@@ -27,12 +27,6 @@ session cookie.
API keys do not expire automatically and act as the user that created them. Every request uses that user's group and
resource permissions.
## Invitation dates
`POST /api/invites` and the tRPC `invite.createInvite` procedure accept `expirationDate` as an ISO 8601 timestamp with a
timezone, for example `2026-12-01T18:00:00Z`. Homarr validates the timestamp and stores it as a date. tRPC callers should
send the ISO string rather than a JavaScript `Date` object.
## Health probes
The liveness endpoint does not require authentication:
@@ -64,8 +64,3 @@ Below you can find instructions for some of the integrations.
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
### Proxmox
For the default Proxmox VE cluster certificates, trust the cluster CA from `/etc/pve/pve-root-ca.pem` on any node.
See [Proxmox certificate management](https://pve.proxmox.com/wiki/Certificate_Management).
@@ -57,5 +57,5 @@ Legacy definitions show **Migration required**. The migration prompt excludes cr
describe the visible behavior separately, import the returned v2 definition, and verify it before removing the archived
version.
Keep `SECRET_ENCRYPTION_KEY` unchanged when updating Homarr so existing credentials remain readable. Multi-replica
installations must use the same Redis service so cached responses invalidate consistently.
Back up the database and `SECRET_ENCRYPTION_KEY` before upgrading or rolling back. Multi-replica installations must use
the same Redis service so cached responses invalidate consistently.
+34 -37
View File
@@ -8,17 +8,18 @@ tags:
# Model Context Protocol (MCP)
Connect an AI client to Homarr to manage boards, apps, integrations, and other services through MCP tools.
Homarr uses MCP v2 (protocol `2026-07-28`) at `/api/mcp`, with compatibility for 2025 Streamable HTTP clients.
Requests are stateless: each request authenticates independently, without an MCP session ID.
Homarr exposes a stateless [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`. It maps the
current Homarr API to tools for compatible AI clients.
![The AI / MCP management tab](./img/mcp.png)
## Connect a client
The endpoint implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients through a compatibility path. The old
`/api/mcp/mcp` route remains as an alias; use `/api/mcp` for new clients.
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
replacing the URL and API key:
## Configure a client
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
MCP configuration:
```json
{
@@ -33,10 +34,7 @@ replacing the URL and API key:
}
```
Use the complete key, including the ID, dot, and token. Keep it private and use HTTPS for remote connections.
The **AI / MCP** tab on Homarr's API page provides your endpoint, configuration, and available tools.
For clients that only support STDIO, use `mcp-remote`:
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
```json
{
@@ -49,30 +47,26 @@ For clients that only support STDIO, use `mcp-remote`:
}
```
### OAuth
The **AI / MCP** tab on Homarr's API page shows the current endpoint, configuration, and generated tool catalog. Filter
the catalog by namespace, tool name, or description; matching categories expand automatically.
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
Homarr publishes discovery metadata under `/.well-known/`.
## Authentication and permissions
If your reverse proxy does not preserve the public host and protocol, set `BASE_URL` to your public origin,
such as `https://homarr.example.com`, without a path.
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
for clients that support dynamic authorization.
## Permissions and tools
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
server and protected resource through Homarr's `/.well-known/` routes.
Tools use the permissions of the API key owner or OAuth user. Queries read data; mutations change it.
Inspect the **AI / MCP** tab or your client's tool list for available actions.
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
Use HTTPS when the endpoint crosses an untrusted network.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
## Verify the endpoint
## Verify the connection
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
The 2025 compatibility path accepts a compact `tools/list` request:
```sh
curl -X POST \
@@ -83,14 +77,17 @@ curl -X POST \
https://homarr.example.com/api/mcp
```
A successful discovery or initialization response alone does not verify that tools can be listed.
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
## Troubleshooting
## Tools and resources
- **`invalid_token`**: copy the complete API key in `<id>.<token>` format.
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
of an unsupported date schema.
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
an alias.
The generated catalog includes available board, app, integration, Docker, Kubernetes, media, DNS, download, and other
Homarr procedures. Queries are read-only; mutations change state. The exact catalog depends on the release and current
user permissions, so inspect `tools/list` or the table in Homarr instead of relying on a static list.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
@@ -18,10 +18,6 @@ password. Decimal units are the default. Change them from the user form or **Com
Preferences → Data size units**. Byte rates follow the same preference; bit rates stay decimal and use labels such as
Mb/s. Deleting a user does not delete boards they created.
In demo mode (`DEMO_MODE=true`), user deletion, password changes, and local username changes are disabled for everyone,
including administrators, even when `DEMO_READ_ONLY=false` allows other changes. With demo editing enabled, other
profile preferences remain editable. This applies to the UI and API.
![The Users management page](../img/users.png)
## Groups
+1 -1
View File
@@ -12,7 +12,7 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The App widget opens an application or website and can show whether Homarr can reach it. Configure the target and status
check from the tile's edit actions. In view mode, right-click uses the browser's link menu; Homarr's widget menu remains available in edit mode.
check from the tile's edit actions.
### Adding the widget
@@ -22,8 +22,6 @@ one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep
preserves its configured columns when the dashboard is scaled. Bookmark cards, icons, text, and gaps keep the same
visual density when a low-column dashboard is enlarged to fill the screen.
Right-click a bookmark link in view mode to use the browser menu, including opening it in a new tab or copying its address.
### Screenshots
import gridIconsOnly from "./img/grid-icons-only.png";
@@ -1,43 +0,0 @@
---
title: "llama.cpp"
description: "Displays the health, loaded model and generation speed of a local llama.cpp llama-server."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { llamacppWidget } from ".";
import { llamacppIntegration } from "@site/docs/integrations/llama-cpp";
<WidgetHeader widget={llamacppWidget} categories={["System Monitoring"]} />
This widget shows the live status of your local [llama.cpp](https://github.com/ggml-org/llama.cpp) `llama-server`: whether it is online, which model is loaded (with quantization, context size, file size and parameter count), the generation speed in tokens per second, and how many requests are being processed. When the server is started with `--metrics`, it also reports total token counts and the prompt cache hit rate.
### Generation speed
The widget picks the most meaningful speed for the current state (hover the speed for an explanation of which one is shown):
- **While a request is being generated** — the average speed of that request since it started, derived from the token count reported by `/slots`.
- **While the server is busy but the per-request data is unavailable** — the live speed from the `/metrics` rate gauge.
- **While the server is idle** — the average generation speed since the server started (cumulative tokens ÷ seconds), so the value stays meaningful instead of dropping to zero.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: llamacppIntegration,
note: "Displays health, model details, generation speed and request status",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={llamacppWidget.configuration} />
-37
View File
@@ -1,37 +0,0 @@
import { WidgetDefinition } from "@site/src/types";
import { IconCpu } from "@tabler/icons-react";
export const llamacppWidget: WidgetDefinition = {
icon: IconCpu,
name: "llama.cpp",
description: "Shows the health, loaded model and generation speed of a local llama.cpp llama-server.",
path: "../../widgets/llama-cpp",
configuration: {
items: [
{
name: "Show title",
description: "Displays the llama.cpp label and status badge at the top of the widget",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show model info",
description: "Displays the loaded model name, quantization, context size and file size",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show cache hit rate",
description: "Displays the prompt cache hit rate as a percentage bar",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show context usage",
description: "Displays the context (KV cache) token usage as a progress bar",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -41,7 +41,6 @@ import temperature from "./img/temperature.png";
### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled).
- After a successful toggle, the displayed state refreshes immediately.
- If Homarr cannot refresh an entity, the last known state stays visible but toggling is disabled until the connection recovers.
<WidgetInteractionGuide />
@@ -51,6 +51,4 @@ import withDisplayname from "./img/with-displayname.png";
### Configuration
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
+2 -5
View File
@@ -2304,8 +2304,7 @@
"traefik",
"customApi",
"assistant",
"wud",
"llamacpp"
"wud"
]
},
"options": {
@@ -2573,9 +2572,7 @@
"type": "object",
"properties": {
"expirationDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
"type": "string"
}
},
"required": [
@@ -1,12 +1,43 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
describe("Copy AI prompt", () => {
it("uses one self-contained prompt without an embedded-skill branch", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(source).toContain("buildCustomWidgetAiPrompt");
expect(source).not.toContain("embedded-authoring-prompt");
expect(source).not.toContain("Menu");
});
it("copies a complete prompt within the public size limit", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
});
it("keeps AI actions and section navigation readable on narrow screens", () => {
const card = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx`,
"utf8",
);
const styles = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
"utf8",
);
const actions = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(card).toContain('<Accordion variant="contained">');
expect(card).not.toContain("defaultValue");
expect(actions).toContain("<ActionIcon");
expect(styles).toContain(".sectionNav > a");
expect(styles).toContain("flex: 0 0 auto");
});
});
@@ -0,0 +1,36 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
describe("legacy custom widget migration UI", () => {
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
expect(list).toContain("widget.migrationRequired");
expect(list).toContain('t("page.list.migrationInstructions")');
expect(actions).toContain("legacyMigrationPrompt.fetch");
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
expect(actions).toContain("navigator.clipboard.readText()");
expect(actions).toContain('t("action.pasteMigration")');
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
expect(actions).toContain("legacyId={widget.id}");
});
it("replaces the legacy record through migration and refreshes every consumer", () => {
// The import pipeline is shared by the dialog and the Workshop install page.
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
expect(importer).toMatch(
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
);
expect(importer).toContain("utils.customWidget.list.invalidate()");
expect(importer).toContain("utils.customWidget.available.invalidate()");
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
expect(dialog).toContain("useCustomWidgetImport({");
expect(dialog).toContain("legacyId,");
});
});
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
@@ -75,6 +76,16 @@ afterEach(async () => {
});
describe("Custom Widget preview panel", () => {
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
"utf8",
);
expect(source).not.toContain("forceColorScheme");
expect(source).not.toContain("onThemeChange");
});
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
await act(async () => {
root.render(
@@ -45,7 +45,7 @@ export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
onSubmit={form.onSubmit((values) => {
mutate(
{
expirationDate: new Date(values.expirationDate).toISOString(),
expirationDate: new Date(values.expirationDate),
},
{
onSuccess: (result) => {
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
import { cookies } from "next/headers";
import { z } from "zod/v4";
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import {
createAssistantGenerationAccessToken,
getAssistantRequestContextEntitiesAsync,
@@ -547,7 +547,19 @@ export async function POST(request: Request) {
}
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
try {
const result = await callMcpTool(caller, mcpTool, executionInput);
const procedure = mcpTool.pathInRouter.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
if (typeof procedure !== "function") {
throw new Error("Procedure not callable");
}
const result = await (procedure as (value: unknown) => Promise<unknown>)(
executionInput && Object.keys(executionInput as object).length > 0 ? executionInput : undefined,
);
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
return toAssistantToolOutput(result, {
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
@@ -2,11 +2,8 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type * as CatalogModule from "../_extract-tools";
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
extractCatalog: vi.fn(),
ipAddress: vi.fn(() => "127.0.0.1"),
toolProcedure: vi.fn(),
loggerWarn: vi.fn(),
@@ -17,9 +14,7 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", async () => ({
callMcpTool: (await import("../../../../../../../packages/api/src/mcp-tools")).callMcpTool,
extractMcpToolsFromProcedures: mocks.extractCatalog,
vi.mock("@homarr/api/mcp", () => ({
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
}));
@@ -59,7 +54,6 @@ vi.mock("../_extract-tools", () => ({
description: "List boards",
type: "query",
pathInRouter: ["board", "getAllBoards"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -67,7 +61,6 @@ vi.mock("../_extract-tools", () => ({
description: "List Custom Widgets",
type: "query",
pathInRouter: ["customWidget", "list"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -75,7 +68,6 @@ vi.mock("../_extract-tools", () => ({
description: "Search Workshop",
type: "query",
pathInRouter: ["customWidget", "workshopSearch"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
],
@@ -313,21 +305,3 @@ describe("MCP protocol errors", () => {
expect(metadata).not.toHaveProperty("message");
});
});
test("logs catalog omissions only when the shared catalog is first built", async () => {
const diagnostic = { name: "broken", path: "broken", reason: "invalid_schema" };
const tools = [{ name: "valid" }];
mocks.extractCatalog.mockReturnValue({ tools, diagnostics: [diagnostic] });
const { extractMcpTools } = await vi.importActual<typeof CatalogModule>("../_extract-tools");
expect(extractMcpTools()).toBe(tools);
expect(extractMcpTools()).toBe(tools);
expect(mocks.extractCatalog).toHaveBeenCalledTimes(1);
expect(mocks.loggerWarn).toHaveBeenCalledExactlyOnceWith("MCP tool omitted from catalog", diagnostic);
});
test("rejects unauthenticated tool calls before execution", async () => {
mocks.authenticate.mockResolvedValue(null);
const { response } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
expect(response.status).toBe(401);
expect(mocks.toolProcedure).not.toHaveBeenCalled();
});
@@ -1,12 +1,11 @@
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
import { createLogger } from "@homarr/core/infrastructure/logs";
const logger = createLogger({ module: "mcp-catalog" });
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
export function extractMcpTools() {
if (cache) return cache.tools;
if (cache) return cache;
cache = extractMcpToolsFromProcedures(mcpRouter);
for (const diagnostic of cache.diagnostics) logger.warn("MCP tool omitted from catalog", diagnostic);
return cache.tools;
return cache;
}
+1 -201
View File
@@ -1,18 +1,12 @@
// @vitest-environment node
import { initTRPC, TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { appManageSchema } from "@homarr/validation/app";
import { extractMcpToolsFromProcedures } from "@homarr/api/mcp";
import type { McpMeta } from "../../../../../../packages/api/src/mcp-tools";
import { createMcpProtocolHandler } from "./_protocol";
vi.mock("@homarr/api/mcp", async () => import("../../../../../../packages/api/src/mcp-tools"));
const modernMetadata = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
@@ -58,7 +52,7 @@ const echoTool: McpTool = {
description: "Echo a message",
type: "query",
pathInRouter: ["echo"],
inputMode: "object",
inputValidator: echoValidator,
inputSchema: z.toJSONSchema(echoValidator),
};
@@ -169,197 +163,3 @@ describe("MCP v2 protocol", () => {
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
});
});
describe.each([false, true])("MCP argument dispatch (modern=%s)", (modern) => {
const trpc = initTRPC.context<{ user: string }>().meta<McpMeta>().create();
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
const call = async (
handler: ReturnType<typeof createMcpProtocolHandler>,
name: string,
argumentsValue: Record<string, unknown>,
) => {
let request = createRequest("tools/call", { name, arguments: argumentsValue }, name);
if (!modern) {
request = new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name, arguments: argumentsValue },
}),
});
}
const response = await handler.fetch(request);
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
return (await parseResponse(response)).result;
};
test("transforms once and does not coerce, insert defaults, or remove properties before tRPC", async () => {
const transform = vi.fn((value: string) => `${value}!`);
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
update: exposed
.input(
z.looseObject({
name: z.string().transform(transform),
limit: z.number().default(7),
}),
)
.mutation(({ input }) => execution(input)),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const realCaller = router.createCaller({ user: "one" });
const dispatch = vi.fn(realCaller.update);
const handler = createMcpProtocolHandler({
caller: { update: dispatch },
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = { name: "hello", extra: "preserved" };
const result = await call(handler, "update", argumentsValue);
expect(result.isError).not.toBe(true);
expect(dispatch).toHaveBeenCalledWith(argumentsValue);
expect(transform).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledWith({ name: "hello!", limit: 7, extra: "preserved" });
await call(handler, "update", { name: "hello", limit: "7" });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledTimes(1);
});
test("retains overlapping chained constraints, refinements, and empty object defaults", async () => {
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
bounded: exposed
.input(z.object({ count: z.number().min(2) }))
.input(z.object({ count: z.number().max(5) }))
.mutation(({ input }) => execution(input)),
refined: exposed
.input(z.object({ value: z.string().refine((value) => value !== "blocked") }))
.mutation(({ input }) => execution(input)),
defaults: exposed.input(z.object({ count: z.number().default(3) })).query(({ input }) => input),
none: exposed.query(({ input }) => ({ absent: input === undefined })),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "one" }),
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const count of [1, 6]) expect((await call(handler, "bounded", { count })).isError).toBe(true);
expect((await call(handler, "refined", { value: "blocked" })).isError).toBe(true);
expect(execution).not.toHaveBeenCalled();
expect((await call(handler, "bounded", { count: 3 })).isError).not.toBe(true);
expect(execution).toHaveBeenCalledWith({ count: 3 });
expect((await call(handler, "defaults", {})).content).toEqual([{ type: "text", text: '{"count":3}' }]);
expect((await call(handler, "none", {})).content).toEqual([{ type: "text", text: '{"absent":true}' }]);
});
test("accepts app inputs that tRPC normalizes before checking constraints", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({ create: exposed.input(appManageSchema).mutation(({ input }) => mutation(input)) });
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = {
name: `${" ".repeat(64)}App `,
description: " description ",
iconUrl: " icon ",
href: null,
pingUrl: " https://example.com ",
};
expect((await call(handler, "create", argumentsValue)).isError).not.toBe(true);
expect(mutation).toHaveBeenCalledExactlyOnceWith({
name: "App",
description: "description",
iconUrl: "icon",
href: null,
pingUrl: "https://example.com",
});
expect((await call(handler, "create", { ...argumentsValue, pingUrl: " javascript:alert(1) " })).isError).toBe(true);
expect(mutation).toHaveBeenCalledTimes(1);
});
test("accepts Zod ISO timestamps without seconds while rejecting invalid dates", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({
invite: exposed
.input(z.object({ expirationDate: z.iso.datetime({ offset: true }) }))
.mutation(({ input }) => mutation(input)),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const expirationDate of ["2026-12-01T18:00Z", "2026-12-01T19:00+01:00"]) {
expect((await call(handler, "invite", { expirationDate })).isError).not.toBe(true);
expect(mutation).toHaveBeenLastCalledWith({ expirationDate });
}
for (const expirationDate of ["2026-02-30T18:00Z", "not-a-date"]) {
expect((await call(handler, "invite", { expirationDate })).isError).toBe(true);
}
expect(mutation).toHaveBeenCalledTimes(2);
});
test("enforces tRPC permissions before executing mutations", async () => {
const mutation = vi.fn(() => "changed");
const router = trpc.router({
restricted: exposed
.use(({ ctx, next }) => {
if (ctx.user !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
return next();
})
.input(z.object({}))
.mutation(mutation),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "guest" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Forbidden",
});
expect((await call(handler, "restricted", {})).isError).toBe(true);
expect(mutation).not.toHaveBeenCalled();
});
test("isolates authenticated callers when reusing catalog schemas", async () => {
const router = trpc.router({ who: exposed.query(({ ctx }) => ctx.user) });
const { tools } = extractMcpToolsFromProcedures(router);
const handlers = ["alice", "bob"].map((user) =>
createMcpProtocolHandler({
caller: router.createCaller({ user }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Failed",
}),
);
const results = await Promise.all(handlers.map((handler) => call(handler, "who", {})));
expect(results.map((result) => result.content)).toEqual([
[{ type: "text", text: '"alice"' }],
[{ type: "text", text: '"bob"' }],
]);
});
});
+22 -6
View File
@@ -1,10 +1,8 @@
import { createMcpHandler, fromJsonSchema, McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { callMcpTool } from "@homarr/api/mcp";
import type { McpTool } from "@homarr/api/mcp";
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
import { serializeMcpToolResult } from "./_serialize-result";
interface CreateMcpProtocolHandlerOptions {
caller: unknown;
@@ -17,6 +15,15 @@ interface CreateMcpProtocolHandlerOptions {
onProtocolError?: (error: Error) => void;
}
const resolveProcedure = (caller: unknown, path: string[]) =>
path.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
export const createMcpProtocolHandler = ({
caller,
tools,
@@ -45,16 +52,25 @@ export const createMcpProtocolHandler = ({
tool.name,
{
description: tool.description,
inputSchema: fromJsonSchema(tool.inputSchema as Parameters<typeof fromJsonSchema>[0]),
inputSchema: tool.inputValidator,
},
async (args) => {
try {
const result = await callMcpTool(caller, tool, args);
const procedure = resolveProcedure(caller, tool.pathInRouter);
if (typeof procedure !== "function") {
return {
content: [{ type: "text" as const, text: `Tool "${tool.name}" is not callable` }],
isError: true,
};
}
const input = Object.keys(args).length > 0 ? args : undefined;
const result = await (procedure as (value: unknown) => Promise<unknown>)(input);
return {
content: [
{
type: "text" as const,
text: serializeMcpToolResult(toAssistantToolOutput(result)),
text: JSON.stringify(toAssistantToolOutput(result)),
},
],
};
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { serializeMcpToolResult } from "./_serialize-result";
describe("serializeMcpToolResult", () => {
it("serializes objects as JSON strings", () => {
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
});
it("serializes primitive values", () => {
expect(serializeMcpToolResult("hello")).toBe('"hello"');
expect(serializeMcpToolResult(42)).toBe("42");
expect(serializeMcpToolResult(true)).toBe("true");
});
it("serializes null as the string null", () => {
expect(serializeMcpToolResult(null)).toBe("null");
});
it("serializes void results as the string null so MCP content text is always a string", () => {
expect(serializeMcpToolResult(undefined)).toBe("null");
});
});
@@ -1,3 +0,0 @@
export function serializeMcpToolResult(result: unknown): string {
return JSON.stringify(result) ?? "null";
}
@@ -5,9 +5,10 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } f
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
@@ -39,7 +40,6 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
const tItem = useI18n("item");
const t = useI18n();
const { openModal } = useModalAction(LazyWidgetEditModal);
const { openConfirmModal } = useConfirmModal();
const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
@@ -143,21 +143,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
<InlineConfirmMenuItem
color="red"
confirmLabel={tItem("remove.message")}
leftSection={<IconTrash size={16} />}
onClick={() => {
setIsMenuOpen(false);
openConfirmModal({
title: tItem("remove.message"),
children: label,
labels: { confirm: tItem("action.remove") },
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
onConfirm={() => removeItem({ itemId: item.id })}
>
{tItem("action.remove")}
</Menu.Item>
</InlineConfirmMenuItem>
</Menu.Dropdown>
</Menu>
);
@@ -244,13 +244,7 @@ export const WidgetContextMenu = ({
setMenuOpened(false);
}, []);
if (
!session ||
!settings.enableRightClickOnWidgets ||
isEditMode ||
item.kind === "app" ||
item.kind === "bookmarks"
) {
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("DynamicBreadcrumb", () => {
it("falls back to the raw segment when a path segment has no translation", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx`,
"utf8",
);
// Dynamic ids (e.g. /manage/custom-widgets/workshop/<id>) have no message and are
// not mapped while their page loads; resolving them must not raise MISSING_MESSAGE.
expect(source).toContain("let label = pathnamePart");
expect(source).toContain("if (mappedValue !== undefined)");
expect(source).toContain("t.has(labelKey)");
expect(source).not.toMatch(/\{t\(`\$\{translationKey\}\.label`/u);
});
});
@@ -1,9 +1,18 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client";
const read = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
const detailPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx";
const browsePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/_workshop-browse.tsx";
const browseRoutePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/page.tsx";
const publishPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx";
const cssButton = "apps/nextjs/src/components/workshop/workshop-css-import-button.tsx";
describe("Workshop widget install flow", () => {
afterEach(() => {
document.head.innerHTML = "";
@@ -30,4 +39,136 @@ describe("Workshop widget install flow", () => {
expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1");
});
it("installs from a page with a sticky footer instead of stacked modals", () => {
const detail = read(detailPage);
expect(detail).toContain("ManageStickyFooter");
expect(detail).toContain("useCustomWidgetImport");
// Review and install happen on one surface, so neither Modal.Stack nor the review dialog is involved.
expect(detail).not.toContain("Modal.Stack");
expect(detail).not.toContain("useModalsStack");
expect(detail).not.toContain("CustomWidgetImportDialog");
});
it("browses the Workshop as a card grid with per-item Workshop links", () => {
const browse = read(browsePage);
const route = read(browseRoutePage);
expect(browse).toContain("WorkshopSubmissionGrid");
expect(browse).toContain("SearchInput");
expect(browse).toContain("TablePagination");
expect(browse).toContain("CustomWidgetTabs");
expect(route).toContain("resolveHomarrUrlConfig");
expect(route).toContain("workshopWebUrl={workshopWebUrl}");
expect(browse).toContain("workshopWebUrl: string");
expect(browse).toContain("href={workshopWebUrl}");
expect(browse).toContain('workshopWebUrl.replace(/\\/+$/u, "")');
expect(browse).toContain("encodeURIComponent(item.id)");
expect(browse).toContain("WorkshopVoteControl");
expect(browse).not.toContain("Modal");
});
it("renders the grid with real cards and screenshot previews", () => {
const grid = read("apps/nextjs/src/components/workshop/workshop-submission-grid.tsx");
const card = read("apps/nextjs/src/components/workshop/workshop-submission-card.tsx");
expect(grid).toContain("SimpleGrid");
expect(card).toContain("Card.Section");
expect(card).toContain("PREVIEW_HEIGHT");
});
it("shows the viewer's own vote as a filled control", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
expect(vote).toContain("useWorkshopUserVotesQuery");
expect(vote).toContain("IconArrowBigUpFilled");
expect(vote).toContain("IconArrowBigDownFilled");
expect(vote).toContain('color={upvoted ? "red" : "gray"}');
expect(vote).toContain("aria-pressed={upvoted}");
});
it("previews screenshots large with a zoom lightbox", () => {
const screenshots = read("apps/nextjs/src/components/workshop/workshop-screenshots.tsx");
const detail = read(detailPage);
expect(screenshots).toContain("1200x800");
expect(screenshots).toContain("zoomScreenshot");
expect(screenshots).toContain('import { Lightbox } from "@mantine/lightbox"');
expect(screenshots).toContain("withThumbnails");
expect(screenshots).toContain("withZoom");
expect(screenshots).toContain("withKeyboardEvents");
expect(screenshots).not.toMatch(/<Modal\b/u);
expect(detail).toContain("WorkshopScreenshots");
});
it("publishes from a page with the same sticky footer", () => {
const publish = read(publishPage);
expect(publish).toContain("ManageStickyFooter");
expect(publish).not.toContain("useModalsStack");
expect(publish).not.toMatch(/<Modal\b/u);
});
it("imports Custom CSS from a single modal", () => {
const css = read(cssButton);
expect(css).toContain("WorkshopSubmissionGrid");
expect(css).toContain("useConfirmModal");
expect(css).not.toContain("useModalsStack");
expect(css).not.toContain("Modal.Stack");
});
it("cannot import the same widget twice and returns to the installed list", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// The mutation creates a new record per call, so a second click must be impossible.
expect(importer).toContain("if (!configuredWidget || pending || succeeded) return;");
expect(importer).toContain("setSucceeded(true)");
expect(detail).toContain("router.push(installedHref)");
expect(detail).toContain("disabled={!compatible || !importer.ready || importer.succeeded}");
// The heavy edit form is no longer the post-install destination.
expect(detail).not.toContain("/manage/custom-widgets/edit/");
});
it("keeps typed credentials across a background refetch of the submission", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// Voting invalidates the detail query. If the setup reset keyed off the widget
// object identity, the refetch would silently clear part-typed API keys.
expect(importer).toContain("const sourcesKey = JSON.stringify(widget?.sources ?? {})");
expect(importer).toContain("[sourcesKey]");
expect(importer).not.toContain("getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget])");
expect(detail).toContain("const content = detail.data?.content;");
expect(detail).toContain("[content]");
});
it("keeps the sign-in hint reachable when voting is unavailable", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
// Mantine tooltips do not fire on disabled controls, so the hint hangs off the wrapper.
expect(vote).toContain('canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>');
});
it("cannot publish the same widget twice", () => {
const publish = read(publishPage);
expect(publish).toContain("if (createSubmission.isPending) return;");
expect(publish).toContain("createSubmission.isPending ||");
});
it("no longer ships the stacked-modal install components", () => {
for (const removed of [
"apps/nextjs/src/components/workshop/workshop-browser.tsx",
"apps/nextjs/src/components/workshop/workshop-install-button.tsx",
"apps/nextjs/src/components/workshop/workshop-installer.tsx",
"apps/nextjs/src/components/workshop/workshop-publish-modal.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-list.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-item.tsx",
]) {
expect(() => read(removed)).toThrow();
}
});
});
+1 -1
View File
@@ -77,7 +77,7 @@ pnpm --filter @homarr/docs typecheck
```
The integration suite uses a disposable Compose project. `pnpm test:workshop-image` additionally builds and smoke-tests
the combined production image. Both commands are manual and do not run in CI. Workshop CI validates Compose configuration only when its workflow or Docker build inputs change, then publishes images on release branches. The main Fast gate owns workspace typechecking.
the combined production image.
## Data safety
+113 -3
View File
@@ -1,7 +1,48 @@
import { readFile } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import vm from "node:vm";
const read = (path) => readFile(path, "utf8");
const read = (path) => readFile(resolve(path), "utf8");
const packageJson = JSON.parse(await read("packages/workshop/package.json"));
const clientExport = packageJson.exports?.["./client"];
if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
await access(resolve("packages/workshop", clientExport));
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
}
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
}
if (!hook.includes('username: "name"') || hook.includes("onRecordAuthWithOAuth2Request")) {
throw new Error("Workshop identity must use PocketBase's direct GitHub username mapping");
}
if (hook.includes("validateAndNormalizeSubmission")) {
throw new Error("PocketBase must store Workshop submissions without interpreting their content");
}
for (const artifact of [
"apps/workshop/pb_hooks/widget-validator.js",
"apps/workshop/pb_hooks/widget-validator.bundle.cjs",
"packages/workshop/src/pocketbase-validator.ts",
"scripts/build-workshop-validator.mjs",
]) {
await access(resolve(artifact))
.then(() => {
throw new Error(`Workshop validator artifact must stay removed: ${artifact}`);
})
.catch((error) => {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
throw error;
});
}
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
if (!hook.includes('routerAdd("GET", "/workshop/{id}"') || !hook.includes('getStringSlice("screenshots")')) {
throw new Error("Workshop detail routes must render server-side social metadata with submission screenshots");
}
const hookUtilsModule = { exports: {} };
vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule });
@@ -28,4 +69,73 @@ for (const expected of [
}
if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags");
console.log("Workshop social metadata rendering passed");
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`);
}
const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js");
for (const required of ['name: "assistant_quotas"', 'name: "assistant_global_quota"', "idx_assistant_quotas_user"]) {
if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`);
}
for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activity"', 'name: "dailyLimit"']) {
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
for (const required of [
'removeIndex("idx_assistant_quotas_user")',
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
]) {
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/model"',
'apis.RequireAuth("users")',
"DefaultActivityLoggerMiddlewareId",
'os.Getenv("OPENROUTER_API_KEY")',
'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_OPENROUTER_MODEL")',
'"data_collection": "deny"',
'"zdr": true',
]) {
if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`);
}
if (/logger\(\)|\.Logger\(|log\.Print/u.test(providerBackend)) {
throw new Error("The Homarr provider must not log request content or user tokens");
}
for (const required of [
"workshop_migration_state",
"addedUserFields",
"cloneRule(users.listRule)",
"CREATE TRIGGER submissions_revision_cas",
"NEW.expectedRevision != OLD.revision",
"users.passwordAuth = state.users.passwordAuth",
"users.oauth2 = state.users.oauth2",
"app.delete(users)",
]) {
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
const workflow = await read(".github/workflows/workshop.yml");
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
const entrypoint = await read("apps/workshop/entrypoint.sh");
if (!entrypoint.includes("/pb_public/workshop-runtime-config.js") || !entrypoint.includes("WORKSHOP_API_URL")) {
throw new Error("Workshop must publish its API URL when the container starts");
}
const docsConfig = await read("apps/docs/docusaurus.config.ts");
if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) {
throw new Error("Workshop must load the container runtime configuration before the documentation bundle");
}
if (!docsConfig.includes('path: "/workshop/:id"') || !docsConfig.includes("priority: -1")) {
throw new Error("Workshop submission routes must not capture the moderation page");
}
await access(resolve("apps/docs/static/workshop-runtime-config.js"));
await access(resolve("apps/docs/src/pages/workshop/admin/index.tsx"));
console.log("Workshop static contracts passed");
+2 -1
View File
@@ -16,7 +16,8 @@ const adminCredentials = {
password: "Comp(exP4sswOrd",
};
// Run this opt-in asset generator with pnpm test:docs-screenshots when the documented UI changes.
// This is an opt-in documentation asset generator, not part of the normal E2E gate.
// Run it explicitly with UPDATE_DOCS_SCREENSHOTS=true when the documented UI changes.
describe.skipIf(process.env.UPDATE_DOCS_SCREENSHOTS !== "true")("Assistant documentation screenshots", () => {
test("captures the management page and conversation panel", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
+114
View File
@@ -0,0 +1,114 @@
import { chromium, expect } from "@playwright/test";
import type { Browser } from "@playwright/test";
import type { StartedTestContainer } from "testcontainers";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import type { MockApiServer } from "./shared/mock-api-server";
import { startMockApiServerAsync } from "./shared/mock-api-server";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
const initialTemplate =
"<Stack><Title order={4}>{data.status?.title}</Title><Badge>{data.status?.status}</Badge></Stack>";
const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>{data.status?.value}</Text></Stack>";
describe("Custom JSX v2 workbench", () => {
test.skip("creates, previews, edits, and immediately adds a widget to a board", async () => {
let mockApi: MockApiServer | undefined;
let homarrContainer: StartedTestContainer | undefined;
let browser: Browser | undefined;
try {
mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 });
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${baseUrl}/auth/login`);
await page.waitForLoadState("networkidle");
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
waitUntil: "commit",
timeout: 60_000,
});
await page.locator("button[type='submit']").click();
await signedIn;
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
await page.locator('input[data-path="name"]:visible').fill("E2E Custom JSX v2");
await page.getByRole("textbox", { name: "Base URL" }).fill(mockApi.url);
await page.getByRole("combobox", { name: "Network access" }).click();
await page.getByRole("option", { name: "private" }).click();
await page.getByRole("group", { name: "status" }).getByRole("textbox", { name: "Path" }).fill("/status");
await page.getByRole("button", { name: "Add option" }).click();
const option = page.getByRole("group", { name: "Option 1" });
await option.getByRole("textbox", { name: "Option name" }).fill("label");
await option.getByRole("textbox", { name: "Default" }).fill("E2E");
const request = page.getByRole("group", { name: "status" });
await request.getByRole("button", { name: "Query, body and behavior" }).click();
await request.getByRole("button", { name: "Add query value" }).click();
await request.getByRole("textbox", { name: "Query key" }).fill("label");
await request.getByRole("combobox", { name: "Value source" }).click();
await page.getByRole("option", { name: "Widget option" }).click();
await request.getByRole("combobox", { name: "Option" }).first().click();
await page.getByRole("option", { name: "label" }).click();
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
await expect(page.locator("#requests-editor")).toContainText('"$option": "label"');
await page.locator("#jsx-editor").fill(initialTemplate);
await page.getByRole("button", { name: "Test and preview" }).last().click();
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
timeout: 15_000,
});
const editPage = page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000, waitUntil: "commit" });
await page.getByRole("button", { name: "Create", exact: true }).last().click();
await editPage;
await page.locator("#jsx-editor").fill(updatedTemplate);
await page.getByRole("button", { name: "Save", exact: true }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
await page.goto(baseUrl);
const tourOverlay = page.locator('[data-onboarding-tour-overlay="true"]');
const skipTour = page.getByRole("button", { name: "Skip tour", exact: true });
try {
await skipTour.waitFor({ state: "visible", timeout: 5_000 });
await skipTour.click();
await expect(tourOverlay).toHaveCount(0);
} catch (error) {
if ((await tourOverlay.count()) > 0) throw error;
}
const editToggle = page.getByTestId("board-edit-mode-toggle");
await editToggle.click();
await expect(editToggle).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 });
await page.getByRole("button", { name: "Add board content", exact: true }).click();
await page
.getByRole("dialog", { name: "Create" })
.getByRole("button", { name: /^Add widget / })
.click();
const picker = page.getByRole("dialog").last();
await picker.getByRole("button", { name: /^E2E Custom JSX v2,/ }).click();
const addDialog = page.getByRole("dialog").last();
await expect(addDialog.getByText("E2E Custom JSX v2")).toBeVisible();
await addDialog.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(page.getByText("Widget definition not found")).not.toBeVisible();
await editToggle.click();
await expect(editToggle).toHaveAttribute("aria-pressed", "false", { timeout: 15_000 });
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
} finally {
await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]);
}
}, 180_000);
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
describe("Home", () => {
test("should open with status code 200", async () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
try {
// Act
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
// Assert
expect(homeResponse.status).toBe(200);
} finally {
await homarrContainer.stop();
}
}, 60_000);
});
+100 -1
View File
@@ -1,9 +1,11 @@
import { mkdir, rm } from "node:fs/promises";
import { mkdir, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { chromium } from "@playwright/test";
import type { Page } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import AdmZip from "adm-zip";
import type { StartedTestContainer } from "testcontainers";
import { describe, expect, test } from "vitest";
import * as sqliteSchema from "../packages/db/schema/sqlite";
@@ -28,6 +30,48 @@ const cleanupSqliteDbAsync = async ({ db, localMountPath }: SqliteTestDatabase)
await rm(localMountPath, { recursive: true, force: true });
};
const createRestoreArchiveAsync = async () => {
const sqlite = await createSqliteDbFileAsync();
const { db, localMountPath } = sqlite;
try {
await db.insert(sqliteSchema.onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
await db.insert(sqliteSchema.boards).values({ id: "restored-board", name: "restored", isPublic: true });
await db.insert(sqliteSchema.layouts).values({
id: "restored-base",
boardId: "restored-board",
name: "Base",
role: "base",
columnCount: 10,
breakpoint: 768,
});
await db.insert(sqliteSchema.sections).values({
id: "restored-root",
boardId: "restored-board",
kind: "empty",
xOffset: 0,
yOffset: 0,
});
db.$client.close();
const zip = new AdmZip();
zip.addFile("db.sqlite", await readFile(path.join(localMountPath, "db", "db.sqlite")));
zip.addFile(
"metadata.json",
Buffer.from(
JSON.stringify({
homarrVersion: "e2e",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: "0".repeat(64),
}),
),
);
return zip.toBuffer();
} finally {
await cleanupSqliteDbAsync(sqlite);
}
};
describe("Onboarding", () => {
test.each([
{ name: "mobile", width: 320, height: 740 },
@@ -136,6 +180,61 @@ describe("Onboarding", () => {
90_000,
);
test.skip("SQLite onboarding restores a validated backup and survives the restart", async () => {
const restoreArchive = await createRestoreArchiveAsync();
const invalidArchive = new AdmZip();
invalidArchive.addFile("db.sqlite", Buffer.from("not-a-database"));
const sqlite = await createSqliteDbFileAsync();
const { localMountPath } = sqlite;
const initialContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
let initialContainerStopped = false;
let restoredContainer: StartedTestContainer | undefined;
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
try {
const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`;
await page.goto(`${initialBaseUrl}/init`);
await page.getByRole("button", { name: "Restore backup" }).click();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "missing-metadata.zip",
mimeType: "application/zip",
buffer: invalidArchive.toBuffer(),
});
await page.getByText("Invalid backup: missing metadata.json").waitFor();
await page.getByRole("button", { name: "Change file" }).click();
await fileInput.setInputFiles({ name: "homarr-backup.zip", mimeType: "application/zip", buffer: restoreArchive });
await page.getByRole("heading", { name: "Backup Contents" }).waitFor({ timeout: 30_000 });
expect(await page.getByText("restored", { exact: true }).isVisible()).toBe(true);
await page.getByRole("button", { name: "Continue to restore" }).click();
await page.getByPlaceholder("I understand").fill("I understand");
const restoreResponse = page.waitForResponse(
(response) => response.url().endsWith("/api/backup/import") && response.request().method() === "POST",
);
await page.getByRole("button", { name: "Restore Database" }).click();
expect((await restoreResponse).status()).toBe(200);
await initialContainer.stop();
initialContainerStopped = true;
restoredContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
const restoredBaseUrl = `http://${restoredContainer.getHost()}:${restoredContainer.getMappedPort(7575)}`;
await page.goto(`${restoredBaseUrl}/boards/restored`);
await page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]').waitFor({ timeout: 30_000 });
expect(page.url()).toMatch(/\/boards\/restored(?:[/?#]|$)/);
} finally {
try {
await browser.close();
if (!initialContainerStopped) await initialContainer.stop();
if (restoredContainer) await restoredContainer.stop();
} finally {
await cleanupSqliteDbAsync(sqlite);
}
}
}, 120_000);
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
const sqlite = await createSqliteDbFileAsync();
const { db, localMountPath } = sqlite;
+50
View File
@@ -0,0 +1,50 @@
import { createServer } from "node:http";
import { getContainerRuntimeClient, TestContainers } from "testcontainers";
import type { Server } from "node:http";
const TESTCONTAINERS_SSHD_LABEL = "org.testcontainers.sshd";
export interface MockApiServer {
url: string;
close: () => Promise<void>;
}
export async function startMockApiServerAsync(responseBody: unknown): Promise<MockApiServer> {
const body = JSON.stringify(responseBody);
const server: Server = await new Promise((resolve) => {
const candidate = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(body);
});
candidate.listen(0, "0.0.0.0", () => resolve(candidate));
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("Failed to start mock API server");
}
try {
await TestContainers.exposeHostPorts(address.port);
const client = await getContainerRuntimeClient();
const forwarder = (await client.container.list()).find(
(container) => container.State === "running" && container.Labels[TESTCONTAINERS_SSHD_LABEL] === "true",
);
const forwarderAddress = forwarder
? Object.values(forwarder.NetworkSettings.Networks).find((network) => network.IPAddress)?.IPAddress
: undefined;
if (!forwarderAddress) throw new Error("Failed to locate the Testcontainers host-port forwarder");
return {
url: `http://${forwarderAddress}:${address.port}`,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
} catch (error) {
server.close();
throw error;
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ http {
# Route websockets traffic to port 3001
location /websockets {
proxy_pass http://127.0.0.1:3001;
proxy_pass http://${HOSTNAME}:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
@@ -18,7 +18,7 @@ http {
# Route all other traffic to port 3000
location / {
proxy_pass http://127.0.0.1:3000;
proxy_pass http://${HOSTNAME}:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+3 -6
View File
@@ -49,15 +49,12 @@
"scripts:update-openapi": "tsx ./scripts/check-openapi-schema.mts --write",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "pnpm -F nextjs start",
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\"",
"test:coverage": "pnpm test --coverage.enabled",
"test:integration": "cross-env NODE_ENV=development CI=true vitest run --project integration --maxWorkers 2",
"test:docs-screenshots": "cross-env UPDATE_DOCS_SCREENSHOTS=true NODE_ENV=development CI=true vitest run --project docs-screenshots",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e --maxWorkers 2",
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
"test:workshop": "sh apps/workshop/test.sh",
"test:workshop-live-provider": "node apps/workshop/tests/openrouter-live-smoke.mjs",
"test:workshop-image": "sh apps/workshop/test-image.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\" --ui",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
},
-2
View File
@@ -51,13 +51,11 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@kubernetes/client-node": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"ajv": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"react": "catalog:",
+61 -115
View File
@@ -1,5 +1,3 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import { addFormats, AjvJsonSchemaValidator } from "@modelcontextprotocol/server/validators/ajv";
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import { z } from "zod/v4";
@@ -17,35 +15,23 @@ export interface McpTool {
type: "query" | "mutation";
pathInRouter: string[];
inputSchema: z.core.JSONSchema.JSONSchema;
inputMode: "none" | "object";
inputValidator: z.ZodObject;
}
export interface McpToolDiagnostic {
name: string;
path: string;
reason: "unsupported_input" | "invalid_schema" | "duplicate_name" | "invalid_name";
}
const emptyInput = z.object({});
export interface McpToolCatalog {
tools: McpTool[];
diagnostics: McpToolDiagnostic[];
}
const emptyInput = z.strictObject({});
const schemaEngine = new Ajv2020({
strict: false,
validateSchema: true,
coerceTypes: false,
useDefaults: false,
removeAdditional: false,
});
addFormats(schemaEngine);
const schemaValidator = new AjvJsonSchemaValidator(schemaEngine);
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
const getObjectInput = (input: unknown): z.ZodObject | null => {
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
const getObjectInput = (input: z.ZodType) => {
if (input instanceof z.ZodObject) return input;
if (input instanceof z.ZodOptional) return getObjectInput(input.unwrap());
if (input instanceof z.ZodOptional) {
const unwrappedInput = input.unwrap();
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
}
return null;
};
@@ -64,110 +50,70 @@ const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
return result;
};
const getProcedureInput = (inputs: z.ZodType[]) => {
if (inputs.length === 0) return emptyInput;
if (inputs.length === 1) {
const input = inputs[0] ?? emptyInput;
return getObjectInput(input) ?? input;
}
const objectInputs = inputs.map(getObjectInput);
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
return null;
};
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
const properties = schema.properties ?? {};
const required = (schema.required ?? []).filter((key) => {
const property = properties[key];
return !property || typeof property !== "object" || !("default" in property);
});
return {
...schema,
type: "object",
properties,
required,
};
};
export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
router: Router<TRoot, TRecord>,
): McpToolCatalog {
): McpTool[] {
const tools: McpTool[] = [];
const diagnostics: McpToolDiagnostic[] = [];
const names = new Map<string, string[]>();
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
if (!isProcedure(procedure)) continue;
const definition = procedure["_def"];
if (definition.type === "subscription") continue;
const metadata = getMcpMetadata(definition.meta);
if (!metadata?.enabled) continue;
const name = metadata.name ?? path.replaceAll(".", "_");
const paths = names.get(name) ?? [];
paths.push(path);
names.set(name, paths);
if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(name)) {
diagnostics.push({ name, path, reason: "invalid_name" });
continue;
}
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
const procedureInput = getProcedureInput(inputs);
const inputs = definition.inputs;
let inputMode: McpTool["inputMode"] = "object";
if (inputs.every((input) => input instanceof z.ZodUndefined || input instanceof z.ZodVoid)) inputMode = "none";
const objectInputs = inputs.map(getObjectInput);
if (inputMode === "object" && !objectInputs.every((input) => input !== null)) {
diagnostics.push({ name, path, reason: "unsupported_input" });
continue;
if (
procedureInput === null ||
(!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success)
) {
console.warn(`[MCP] Procedure ${path} does not use an object input schema; using an empty schema`);
}
const inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
try {
let input: z.ZodType = emptyInput;
if (inputMode === "object") {
const objects = objectInputs.filter((schema): schema is z.ZodObject => schema !== null);
input = objects[0] ?? emptyInput;
for (const object of objects.slice(1)) input = z.intersection(input, object);
}
const inputSchema = z.toJSONSchema(input, {
io: "input",
override: ({ zodSchema, jsonSchema }) => {
if (zodSchema["_zod"].def.type !== "string") return;
if (zodSchema["_zod"].def.checks?.some((check) => check["_zod"].def.check === "overwrite")) {
// trim/case normalization runs before these checks in tRPC. They cannot constrain raw JSON.
delete jsonSchema.minLength;
delete jsonSchema.maxLength;
delete jsonSchema.pattern;
delete jsonSchema.allOf;
delete jsonSchema.format;
}
// Zod's ISO regex permits minute precision; JSON Schema date-time requires seconds.
if (jsonSchema.format === "date-time" && typeof jsonSchema.pattern === "string") {
delete jsonSchema.format;
}
},
});
// Chained tRPC object inputs produce allOf; MCP requires an explicit object root.
if (inputSchema.type !== undefined && inputSchema.type !== "object") throw new Error("Invalid root");
inputSchema.type = "object";
// Zod and the SDK disagree on JSON Schema vocabulary types; compilation checks the actual schema.
schemaValidator.getValidator(inputSchema as Parameters<typeof schemaValidator.getValidator>[0]);
tools.push({
name,
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputMode,
inputSchema,
});
} catch {
// Conversion errors can contain schema metadata. Only expose a stable, safe reason.
diagnostics.push({ name, path, reason: "invalid_schema" });
}
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
z.toJSONSchema(inputValidator, {
unrepresentable: "any",
}),
),
});
}
const duplicates = new Set<string>();
for (const [name, paths] of names) {
if (paths.length < 2) continue;
duplicates.add(name);
for (const path of paths) diagnostics.push({ name, path, reason: "duplicate_name" });
}
return {
tools: tools
.filter((tool) => !duplicates.has(tool.name))
.toSorted((left, right) => left.name.localeCompare(right.name)),
diagnostics: diagnostics.toSorted(
(left, right) => left.path.localeCompare(right.path) || left.reason.localeCompare(right.reason),
),
};
}
/** Dispatch raw JSON through the caller so tRPC owns parsing, transforms, and authorization. */
export async function callMcpTool(caller: unknown, tool: McpTool, input: unknown) {
let procedure = caller;
for (const segment of tool.pathInRouter) {
if ((typeof procedure !== "object" && typeof procedure !== "function") || procedure === null) {
throw new Error("Tool is not callable");
}
procedure = (procedure as Record<string, unknown>)[segment];
}
if (typeof procedure !== "function") throw new Error("Tool is not callable");
let procedureInput = input;
if (tool.inputMode === "none") procedureInput = undefined;
else if (input === undefined) procedureInput = {};
return await (procedure as (value: unknown) => Promise<unknown>)(procedureInput);
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
}
+1 -1
View File
@@ -1,5 +1,5 @@
export { createTRPCContext } from "./trpc";
export { callMcpTool, extractMcpToolsFromProcedures } from "./mcp-tools";
export { extractMcpToolsFromProcedures } from "./mcp-tools";
export type { McpTool } from "./mcp-tools";
import { createTRPCRouter } from "./trpc";
+2 -2
View File
@@ -56,7 +56,7 @@ export const inviteRouter = createTRPCRouter({
.requiresPermission("admin")
.input(
z.object({
expirationDate: z.iso.datetime({ offset: true }),
expirationDate: z.date(),
}),
)
.output(z.object({ id: z.string(), token: z.string() }))
@@ -80,7 +80,7 @@ export const inviteRouter = createTRPCRouter({
await ctx.db.insert(invites).values({
id,
expirationDate: new Date(input.expirationDate),
expirationDate: input.expirationDate,
creatorId: ctx.session.user.id,
token,
});
+1 -1
View File
@@ -123,7 +123,7 @@ describe("create should create a new invite expiring on the specified date with
// Act
const result = await caller.createInvite({
expirationDate: expirationDate.toISOString(),
expirationDate,
});
// Assert
+1 -22
View File
@@ -454,7 +454,7 @@ export const userRouter = createTRPCRouter({
}
const user = await ctx.db.query.users.findFirst({
columns: { name: true, email: true, provider: true },
columns: { email: true, provider: true },
where: eq(users.id, input.id),
});
@@ -472,13 +472,6 @@ export const userRouter = createTRPCRouter({
});
}
if (isDemoMode && input.name !== user.name) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Username changes are disabled in demo mode",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
const emailDirty = input.email && user.email !== input.email;
@@ -504,13 +497,6 @@ export const userRouter = createTRPCRouter({
mcp: { enabled: true, description: "Delete a user by ID. REQUIRED: userId (string)" },
})
.mutation(async ({ input, ctx }) => {
if (isDemoMode) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User deletion is disabled in demo mode",
});
}
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
@@ -533,13 +519,6 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ ctx, input }) => {
if (isDemoMode) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Password changes are disabled in demo mode",
});
}
const user = ctx.session.user;
// Only admins can change other users' passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
-1
View File
@@ -46,5 +46,4 @@ export const widgetRouter = createTRPCRouter({
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
llamacpp: lazy(() => import("./llamacpp").then((mod) => mod.llamacppRouter)),
});
@@ -1,29 +0,0 @@
import { llamacppStatsRequestHandler } from "@homarr/request-handler/llamacpp";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const llamacppRouter = createTRPCRouter({
getStats: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Returns health, loaded model and Prometheus metrics (generation speed, token counts, request queue) from a llama.cpp (llama-server) integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneWidgetIntegrationMiddleware("query", "llamacpp"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") {
return { stats: mockWidgetData.llamacpp, updatedAt: new Date(mockWidgetData.timestamp) };
}
const handler = llamacppStatsRequestHandler.handler({ ...ctx.integration, kind: "llamacpp" }, {});
const { data, timestamp } = await handler.getDataAsync();
return {
stats: data,
updatedAt: timestamp,
};
}),
});
+1 -1
View File
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
return "http://127.0.0.1:3000";
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
}
export const trpcPath = "/api/trpc";
+8 -128
View File
@@ -1,12 +1,9 @@
import { initTRPC } from "@trpc/server";
import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import { mcpRouter } from "../mcp";
import type { McpMeta } from "../mcp-tools";
import { createMcpProtocolHandler } from "../../../../apps/nextjs/src/app/api/mcp/_protocol";
import { callMcpTool, extractMcpToolsFromProcedures } from "../mcp-tools";
import { extractMcpToolsFromProcedures } from "../mcp-tools";
vi.mock("@homarr/auth", () => ({}));
@@ -160,7 +157,7 @@ function actualToolInventory() {
{ _def?: { type?: "query" | "mutation" } }
>;
return extractMcpToolsFromProcedures(mcpRouter)
.tools.map((tool) => {
.map((tool) => {
const procedure = procedures[tool.pathInRouter.join(".")];
const type = procedure?.["_def"]?.type;
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
@@ -178,7 +175,7 @@ describe("production MCP router", () => {
});
test("keeps every secret-bearing tool behind a mutation procedure", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
const tools = extractMcpToolsFromProcedures(mcpRouter);
const procedures = mcpRouter["_def"].procedures as unknown as Record<
string,
{ _def?: { type?: "query" | "mutation" } }
@@ -192,7 +189,7 @@ describe("production MCP router", () => {
});
test("gives every production tool a description", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
const tools = extractMcpToolsFromProcedures(mcpRouter);
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
"Get the current Custom Widget authoring instructions.",
@@ -200,17 +197,16 @@ describe("production MCP router", () => {
});
});
test("MCP tools are deterministically ordered and have valid production schemas", () => {
expect(extractMcpToolsFromProcedures(mcpRouter).diagnostics).toEqual([]);
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
test("MCP tools are deterministically ordered and retain executable schemas", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
expect(tools.every((tool) => tool.inputSchema.type === "object")).toBe(true);
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
});
test("publishes both Custom Widget template input formats", () => {
const tool = extractMcpToolsFromProcedures(mcpRouter).tools.find(
const tool = extractMcpToolsFromProcedures(mcpRouter).find(
(candidate) => candidate.name === "customWidget_validateTemplate",
);
@@ -359,119 +355,3 @@ describe("custom widget authoring procedure access", () => {
);
});
});
describe("MCP catalog diagnostics", () => {
const trpc = initTRPC.meta<McpMeta>().create();
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
test("omits unsupported parsers and malformed schemas without losing valid tools", () => {
const router = trpc.router({
good: exposed.input(z.object({ name: z.string() })).query(() => "ok"),
date: exposed.input(z.object({ date: z.date() })).query(() => "unused"),
scalar: exposed.input(z.string()).query(() => "unused"),
parser: exposed.input({ parse: (value: unknown) => value }).query(() => "unused"),
malformed: exposed
.input(z.object({ value: z.string().meta({ type: "invalid-json-type" }) }))
.query(() => "unused"),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
expect(catalog.diagnostics).toEqual([
{ name: "date", path: "date", reason: "invalid_schema" },
{ name: "malformed", path: "malformed", reason: "invalid_schema" },
{ name: "parser", path: "parser", reason: "unsupported_input" },
{ name: "scalar", path: "scalar", reason: "unsupported_input" },
]);
});
test("omits all duplicate names even when one procedure has an invalid schema", () => {
const shared = trpc.procedure.meta({ mcp: { enabled: true, name: "shared" } });
const catalog = extractMcpToolsFromProcedures(
trpc.router({
first: shared.query(() => "first"),
second: shared.input(z.object({ date: z.date() })).query(() => "second"),
good: exposed.query(() => "good"),
invalidName: trpc.procedure.meta({ mcp: { enabled: true, name: "bad name" } }).query(() => "unused"),
}),
);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
expect(catalog.diagnostics.filter((diagnostic) => diagnostic.reason === "duplicate_name")).toEqual([
{ name: "shared", path: "first", reason: "duplicate_name" },
{ name: "shared", path: "second", reason: "duplicate_name" },
]);
expect(catalog.diagnostics).toContainEqual({ name: "bad name", path: "invalidName", reason: "invalid_name" });
});
test("distinguishes empty object input from no input for shared Assistant dispatch", async () => {
const router = trpc.router({
none: exposed.query(({ input }) => input),
explicitNone: exposed.input(z.undefined()).query(({ input }) => input),
object: exposed.input(z.object({ value: z.string().default("default") })).query(({ input }) => input),
optional: exposed
.input(z.object({ value: z.string().default("default") }).optional())
.query(({ input }) => input),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
for (const tool of catalog.tools) {
const result = await callMcpTool(router.createCaller({}), tool, {});
if (tool.inputMode === "none") expect(result).toBeUndefined();
else expect(result).toEqual({ value: "default" });
}
});
});
test.each([false, true])(
"production catalog registers through the real protocol handler (modern=%s)",
async (modern) => {
const { tools, diagnostics } = extractMcpToolsFromProcedures(mcpRouter);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: {},
tools,
version: "test",
instructions: "test",
formatToolError: () => "Tool failed",
});
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
};
const params: Record<string, unknown> = {};
if (modern) {
headers["Mcp-Method"] = "tools/list";
headers["Mcp-Name"] = "homarr";
params["_meta"] = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "catalog-test", version: "1" },
"io.modelcontextprotocol/clientCapabilities": {},
};
}
const response = await handler.fetch(
new Request("http://homarr.test/api/mcp", {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params }),
}),
);
expect(response.status).toBe(200);
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
const body = await response.text();
let payload = body;
if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
payload =
body
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6) ?? "";
}
const result = JSON.parse(payload) as {
error?: unknown;
result: { tools: { name: string; inputSchema: unknown }[] };
};
expect(result.error).toBeUndefined();
expect(result.result.tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema }))).toEqual(
tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema })),
);
},
);
@@ -14,6 +14,7 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import type { TrustedCertificateHostname } from "../certificates/hostnames";
import { withTimeoutAsync } from "./timeout";
export const createCustomCheckServerIdentity = (
trustedHostnames: TrustedCertificateHostname[],
): typeof checkServerIdentity => {
@@ -37,6 +37,12 @@ describe("authoring resources", () => {
expect(bundledContent).toContain(content.trim());
}
expect(bundledContent).toContain(skill.skillMd.trim());
expect(bundledContent).toContain("coordinated set of widgets");
expect(bundledContent).toContain("customWidget_validateTemplate");
expect(bundledContent).toContain("customWidget_findComponents");
expect(bundledContent).toContain("Batch selected non-obvious binding or interaction documentation");
expect(bundledContent).not.toContain("at most eight named component documents");
expect(bundledContent).toContain("Do not use an unlabeled decorative icon as an empty state");
expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000);
const entrypoint = getCustomWidgetSkillEntrypoint();
@@ -44,7 +50,36 @@ describe("authoring resources", () => {
expect.arrayContaining([expect.objectContaining({ name: "runtime", tool: "customWidget_getReference" })]),
);
expect(JSON.stringify(entrypoint)).not.toContain("# Runtime");
expect(entrypoint.skillMd).toContain("Context routing");
expect(entrypoint.skillMd).toContain("customWidget_validateTemplate");
expect(entrypoint.skillMd).toContain("customWidget_previewReviseTemplate");
expect(entrypoint.skillMd).toContain("map every requested capability");
expect(entrypoint.skillMd).toContain("purpose-specific visual signature");
expect(entrypoint.skillMd).toContain("asymmetric priority metric/action");
expect(entrypoint.skillMd).toContain("new manifest");
expect(entrypoint.skillMd).not.toContain("To install an existing widget");
expect(getCustomWidgetSkillReference("runtime").content).toContain("SubFetch");
expect(getCustomWidgetSkillReference("runtime").content).toContain(
'require `trigger: "manual"` on request and `SubFetch`',
);
expect(getCustomWidgetSkillReference("runtime").content).toContain("RefreshButton");
expect(getCustomWidgetSkillReference("runtime").content).toContain("owns loading/error/retry");
expect(getCustomWidgetSkillReference("runtime").content).toContain("need literal `requestId`");
expect(getCustomWidgetSkillReference("runtime").content).toContain('requestId="search" label="Run again"');
expect(getCustomWidgetSkillReference("runtime").content).toContain("Render load queries directly");
expect(getCustomWidgetSkillReference("runtime").content).toContain("entire JSON response");
expect(getCustomWidgetSkillReference("runtime").content).toContain("never map the envelope itself");
expect(getCustomWidgetSkillReference("runtime").content).toContain("immediately hides its prior result");
expect(getCustomWidgetSkillReference("runtime").content).toContain("Every stateful control must use `bind`");
expect(getCustomWidgetSkillReference("runtime").content).toContain("resetKey={inputs.search}");
expect(getCustomWidgetSkillReference("runtime").content).toContain('Date.toLocaleString(value, "en-US", "UTC")');
expect(getCustomWidgetSkillReference("runtime").content).toContain("Never invent a formatter component");
const schemaReference = getCustomWidgetSkillReference("schema").content;
expect(schemaReference).toContain('"default": {');
expect(schemaReference).toContain('"trigger": "manual"');
expect(schemaReference).toContain('"kind": "action"');
expect(schemaReference).toContain('"apiKeyHeader"');
expect(schemaReference).toContain("is the required source ID");
expect(Buffer.byteLength(schemaReference, "utf8")).toBeLessThan(4_000);
expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan(3_000);
expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan(
@@ -72,6 +107,8 @@ describe("authoring resources", () => {
const serviceDashboard = getCustomWidgetExample("service-dashboard");
if (!serviceDashboard) throw new Error("Service dashboard example is missing");
expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]);
expect(serviceDashboard.widget.template.match(/<Paper/gu)).toHaveLength(1);
expect(serviceDashboard.widget.template).toContain('RefreshButton label="Refresh service health"');
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
expect(catalog.sharedProps).toMatchObject({
count: expect.any(Number),
@@ -0,0 +1,74 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
describe("Workshop navigation translations", () => {
test("defines the breadcrumb labels for the Custom Widgets sub-routes", async () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
management?: { navbar?: { items?: { workshop?: unknown } } };
navigationStructure?: {
manage?: {
workshop?: unknown;
"custom-widgets"?: {
workshop?: { label?: unknown };
publish?: { label?: unknown };
};
};
};
customWidget?: { page?: { tabs?: Record<string, unknown> } };
workshop?: Record<string, unknown>;
};
// The Workshop is reached through the Custom Widgets tabs, not its own navbar entry.
expect(messages.management?.navbar?.items?.workshop).toBeUndefined();
expect(messages.navigationStructure?.manage?.workshop).toBeUndefined();
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.workshop?.label).toBe("Workshop");
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.publish?.label).toBe("Publish to Workshop");
expect(messages.customWidget?.page?.tabs).toMatchObject({
installed: "Installed",
workshop: "Workshop",
ariaLabel: expect.any(String),
});
expect(messages.workshop).toMatchObject({
communityMember: "Community member",
outdated: "Outdated",
install: "Install",
back: expect.any(String),
listAriaLabel: expect.any(String),
installReviewTitle: expect.any(String),
emptyFilteredDescription: expect.any(String),
importCssConfirm: expect.any(String),
reportCount: expect.any(String),
reportWarning: expect.any(String),
reportVisibility: expect.any(String),
screenshotAlt: expect.any(String),
upvote: expect.any(String),
downvote: expect.any(String),
reportCategory: expect.objectContaining({
outdated: expect.any(String),
malicious: expect.any(String),
spam: expect.any(String),
copyright: expect.any(String),
inappropriate: expect.any(String),
other: expect.any(String),
}),
publish: expect.objectContaining({
invalidScreenshot: expect.any(String),
}),
});
});
test("drops the message keys that only existed for the stacked install modals", async () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
workshop?: Record<string, unknown>;
};
for (const key of ["installDialog", "continueInstall", "confirmInstallTitle", "backToWidget", "inspectCss"]) {
expect(messages.workshop).not.toHaveProperty(key);
}
});
});
-1
View File
@@ -505,7 +505,6 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
-1
View File
@@ -503,7 +503,6 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
-1
View File
@@ -488,7 +488,6 @@ export const assistantConfigurations = sqliteTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
-10
View File
@@ -11,7 +11,6 @@ export const assistantProviderIds = [
"together",
"ollama",
"lm-studio",
"llama-cpp",
"custom",
] as const;
@@ -157,15 +156,6 @@ export const assistantProviderPresets = {
discoveryAuthentication: "bearer",
iconUrl: `${providerIconBaseUrl}/lmstudio.svg`,
},
"llama-cpp": {
label: "llama.cpp",
baseUrl: "http://localhost:8080/v1",
modelDiscoveryPath: "/models",
requiresApiKey: false,
category: "local",
discoveryAuthentication: "bearer",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
},
custom: {
label: undefined,
baseUrl: "",
@@ -333,7 +333,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/traefik"
| "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/whats-up-docker"
| "/docs/integrations/llama-cpp"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/air-quality"
@@ -391,5 +390,4 @@ export type HomarrDocumentationPath =
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api"
| "/docs/widgets/assistant"
| "/docs/widgets/whats-up-docker"
| "/docs/widgets/llama-cpp";
| "/docs/widgets/whats-up-docker";
@@ -60,5 +60,4 @@ export const widgetDocSlugs = {
customApi: "custom-api",
assistant: "assistant",
wud: "whats-up-docker",
llamacpp: "llama-cpp",
} satisfies Record<WidgetKind, string | null>;
-9
View File
@@ -545,14 +545,6 @@ export const integrationDefs = {
documentationSlug: "whats-up-docker",
defaultPort: 3000,
},
llamacpp: {
name: "llama.cpp",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
category: ["systemMonitoring"],
documentationSlug: "llama-cpp",
defaultPort: 8080,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -721,7 +713,6 @@ export const integrationCategories = [
"uptimeMonitoring",
"subtitleManager",
"reverseProxy",
"systemMonitoring",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
@@ -48,7 +48,6 @@ describe("widget integration config", () => {
archiveTeamWarrior: 1,
anchorNote: 1,
wud: 1,
llamacpp: 1,
});
});
});
@@ -87,7 +87,6 @@ export const widgetIntegrationConfigs = {
anchorNote: { supportedIntegrations: ["anchor", "mock"], maxIntegrations: 1 },
traefik: { supportedIntegrations: ["traefik", "mock"] },
wud: { supportedIntegrations: ["wud", "mock"], maxIntegrations: 1 },
llamacpp: { supportedIntegrations: ["llamacpp", "mock"], maxIntegrations: 1 },
} satisfies Partial<Record<WidgetKind, WidgetIntegrationConfig>>;
export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs;
-1
View File
@@ -58,7 +58,6 @@ export const widgetKinds = [
"customApi",
"assistant",
"wud",
"llamacpp",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -110,8 +110,6 @@ const integrationCreators = {
traefik: async (input: IntegrationInput) =>
new (await import("../traefik/traefik-integration")).TraefikIntegration(input),
wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input),
llamacpp: async (input: IntegrationInput) =>
new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input),
} satisfies Record<IntegrationKind, (input: IntegrationInput) => Promise<Integration>>;
type IntegrationCreators = typeof integrationCreators;
@@ -134,10 +134,7 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
*/
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: {
...this.getAuthHeaders(),
"Content-Type": "application/json",
},
headers: this.getAuthHeaders(),
body: JSON.stringify(body),
method: "POST",
});
-2
View File
@@ -1,7 +1,6 @@
// General integrations
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
export { WudIntegration } from "./wud/wud-integration";
export { LlamacppIntegration } from "./llama-cpp/llamacpp-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration";
@@ -96,7 +95,6 @@ export type {
ArchiveTeamWarriorStatus,
} from "./archive-team-warrior/archive-team-warrior-types";
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
export type { LlamacppStats, LlamacppModel } from "./llama-cpp/llamacpp-types";
// Schemas
export { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
@@ -1,83 +0,0 @@
import { ParseError, ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { LlamacppStats } from "./llamacpp-types";
import {
mapContextUsage,
mapLlamacppModel,
mapLlamacppPerRequest,
mapLlamacppStats,
mapSlotsSummary,
parseLlamacppHealthAsync,
parseLlamacppMetricsAsync,
parseLlamacppModelsAsync,
parseLlamacppSlotsAsync,
} from "./llamacpp-types";
const LLMACPP_REQUEST_TIMEOUT_MS = 10_000;
export class LlamacppIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/health"), {
signal: AbortSignal.timeout(LLMACPP_REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
// Validate the health payload shape, not just that it is parseable JSON: an
// unrelated JSON body must not pass connection testing.
try {
await parseLlamacppHealthAsync(response);
} catch (error) {
if (error instanceof ParseError) {
return TestConnectionError.ParseResult(error);
}
return TestConnectionError.UnknownResult(error);
}
return { success: true };
}
public async getStatsAsync(): Promise<LlamacppStats> {
const [healthResponse, modelsResponse, metricsResponse, slotsResponse] = await Promise.all([
fetchWithTrustedCertificatesAsync(this.url("/health"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }),
fetchWithTrustedCertificatesAsync(this.url("/v1/models"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }),
fetchWithTrustedCertificatesAsync(this.url("/metrics"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }),
// /slots is not present on every build; a missing endpoint degrades to no context usage.
fetchWithTrustedCertificatesAsync(this.url("/slots"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }).catch(() => null),
]);
if (!healthResponse.ok) {
throw new ResponseError(healthResponse);
}
if (!modelsResponse.ok) {
throw new ResponseError(modelsResponse);
}
// /metrics only exists when llama-server was started with --metrics; a non-OK
// response (typically 501 Not Implemented) degrades to no rate counters instead
// of dropping the health and model data that the other endpoints still provide.
const metricsAvailable = metricsResponse.ok;
const [health, models, metrics, slots] = await Promise.all([
parseLlamacppHealthAsync(healthResponse),
parseLlamacppModelsAsync(modelsResponse),
metricsAvailable ? parseLlamacppMetricsAsync(metricsResponse) : Promise.resolve([]),
slotsResponse?.ok ? parseLlamacppSlotsAsync(slotsResponse) : Promise.resolve(null),
]);
const firstModel = models.data[0] ? mapLlamacppModel(models.data[0]) : null;
const contextUsage = mapContextUsage(slots);
const slotsSummary = mapSlotsSummary(slots);
const perRequest = mapLlamacppPerRequest(slots);
return mapLlamacppStats(health, firstModel, metrics, contextUsage, slotsSummary, perRequest);
}
}
@@ -1,381 +0,0 @@
import { ParseError } from "@homarr/common/server";
import { z } from "zod/v4";
export const LLMACPP_HEALTH_PARSE_ERROR_MESSAGE = "Invalid llama.cpp /health response";
export const LLMACPP_MODELS_PARSE_ERROR_MESSAGE = "Invalid llama.cpp /v1/models response";
export const parsePrometheusMetrics = (text: string): { name: string; value: number }[] => {
const metrics: { name: string; value: number }[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) {
continue;
}
const match = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{[^}]*\})?\s+(-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|NaN)$/.exec(
line,
);
if (!match) {
continue;
}
const name = match[1];
const rawValue = match[2];
if (!name || rawValue === undefined) {
continue;
}
const value = Number.parseFloat(rawValue);
if (!Number.isFinite(value)) {
continue;
}
metrics.push({ name, value });
}
return metrics;
};
export const llamacppHealthSchema = z.object({
status: z.string(),
});
const llamacppModelMetaSchema = z
.object({
n_ctx: z.number().optional(),
n_params: z.number().optional(),
size: z.number().optional(),
ftype: z.string().optional(),
})
.optional();
const llamacppModelSchema = z.object({
id: z.string(),
meta: llamacppModelMetaSchema,
});
export const llamacppModelsSchema = z.object({
data: z.array(llamacppModelSchema),
});
export interface LlamacppModel {
id: string;
name: string;
contextSize: number | null;
parameterCount: number | null;
fileSizeBytes: number | null;
quantization: string | null;
}
export interface LlamacppPerRequest {
taskId: number | null;
decodedTokens: number | null;
}
export interface LlamacppSlotsSummary {
total: number;
processing: number;
decoding: number;
speculative: boolean | null;
}
export interface LlamacppStats {
health: string;
model: LlamacppModel | null;
contextUsage: LlamacppContextUsage | null;
slots: LlamacppSlotsSummary | null;
metrics: {
generationSpeedTps: number | null;
promptSpeedTps: number | null;
avgGenerationSpeedTps: number | null;
avgPromptSpeedTps: number | null;
requestsProcessing: number | null;
requestsDeferred: number | null;
tokensProcessed: number | null;
tokensGenerated: number | null;
promptCacheHitRate: number | null;
requestDecodedTokens: number | null;
taskId: number | null;
specDraftTokens: number | null;
specAcceptedTokens: number | null;
specDrafts: number | null;
};
}
const shortModelName = (id: string): string =>
id
.split("/")
.pop()
?.replace(/\.gguf$/i, "") ?? id;
export const mapLlamacppModel = (model: z.infer<typeof llamacppModelSchema>): LlamacppModel => ({
id: model.id,
name: shortModelName(model.id),
contextSize: model.meta?.n_ctx ?? null,
parameterCount: model.meta?.n_params ?? null,
fileSizeBytes: model.meta?.size ?? null,
quantization: model.meta?.ftype ?? null,
});
export const getMetricValue = (metrics: readonly { name: string; value: number }[], name: string): number | null =>
metrics.find((metric) => metric.name === name)?.value ?? null;
/**
* Averages cumulative counters (tokens ÷ seconds) so a stable number is available while
* the server is idle, mirroring how llama-monitor derives its always-on speeds.
*/
export const avgTokensPerSecond = (tokens: number | null, seconds: number | null): number | null =>
tokens !== null && seconds !== null && seconds > 0 ? tokens / seconds : null;
interface LlamacppSlot {
n_ctx?: number;
n_prompt_tokens?: number;
id_task?: number;
is_processing?: boolean;
speculative?: boolean;
next_token?: { n_decoded?: number }[];
}
export interface LlamacppContextUsage {
usedTokens: number;
contextSize: number;
percent: number;
}
const clampPercent = (value: number): number => Math.min(100, Math.max(0, value));
/**
* Computes context (KV) usage from the /slots endpoint. The /metrics endpoint has no
* KV metrics, so the largest used prompt tokens over the slot context size is used.
*/
export const mapContextUsage = (slots: readonly unknown[] | null): LlamacppContextUsage | null => {
if (!Array.isArray(slots)) {
return null;
}
let largest: LlamacppContextUsage | null = null;
for (const slot of slots) {
if (typeof slot !== "object" || slot === null) {
continue;
}
const record = slot as Partial<LlamacppSlot>;
const contextSize = record.n_ctx;
const usedTokens = record.n_prompt_tokens;
if (typeof contextSize === "number" && contextSize > 0 && typeof usedTokens === "number" && usedTokens >= 0) {
const usage: LlamacppContextUsage = {
usedTokens,
contextSize,
percent: clampPercent(Math.round((usedTokens / contextSize) * 1000) / 10),
};
if (largest === null || usage.usedTokens > largest.usedTokens) {
largest = usage;
}
}
}
return largest;
};
/**
* Summarizes the /slots endpoint: total slot count, how many are currently
* processing, how many are actively decoding, and whether any slot has
* speculative decoding enabled. Null when /slots is unavailable.
*/
export const mapSlotsSummary = (slots: readonly unknown[] | null): LlamacppSlotsSummary | null => {
if (!Array.isArray(slots) || slots.length === 0) {
return null;
}
let total = 0;
let processing = 0;
let decoding = 0;
let speculative: boolean | null = null;
for (const slot of slots) {
if (typeof slot !== "object" || slot === null) {
continue;
}
total += 1;
const record = slot as Partial<LlamacppSlot>;
if (record.is_processing === true) {
processing += 1;
if (Array.isArray(record.next_token) && record.next_token.length > 0) {
decoding += 1;
}
}
if (record.speculative === true) {
speculative = true;
} else if (typeof record.speculative === "boolean" && speculative !== true) {
speculative = record.speculative;
}
}
return total > 0
? {
total,
processing,
decoding,
speculative,
}
: null;
};
/**
* Extracts the active request's identity and generated-token count from /slots. `id_task`
* identifies the in-flight request (llama.cpp task IDs are monotonically increasing and
* never reused) and `next_token[0].n_decoded` is `stats.n_gen`, the tokens this request has
* generated so far, which the server resets to 0 whenever a new generation starts. The widget
* takes deltas between polls to derive the per-request average speed.
*/
export const mapLlamacppPerRequest = (slots: readonly unknown[] | null): LlamacppPerRequest => {
if (!Array.isArray(slots)) {
return { taskId: null, decodedTokens: null };
}
for (const slot of slots) {
if (typeof slot !== "object" || slot === null) {
continue;
}
const record = slot as LlamacppSlot;
if (record.is_processing !== true) {
continue;
}
const taskId = typeof record.id_task === "number" ? record.id_task : null;
const nextToken = record.next_token?.[0];
const decodedTokens = typeof nextToken?.n_decoded === "number" ? nextToken.n_decoded : null;
return { taskId, decodedTokens };
}
return { taskId: null, decodedTokens: null };
};
export const requestSpeedTps = (tokens: number | null, elapsedSeconds: number | null): number | null => {
if (tokens === null || elapsedSeconds === null || elapsedSeconds <= 0) {
return null;
}
const speed = tokens / elapsedSeconds;
return Number.isFinite(speed) && speed > 0 ? speed : null;
};
export const parseLlamacppSlotsAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<unknown[] | null> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError("Invalid llama.cpp /slots response", {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
return Array.isArray(json) ? json : null;
};
export const mapLlamacppStats = (
health: z.infer<typeof llamacppHealthSchema>,
model: LlamacppModel | null,
metrics: readonly { name: string; value: number }[],
contextUsage: LlamacppContextUsage | null,
slotsSummary: LlamacppSlotsSummary | null,
perRequest: LlamacppPerRequest,
): LlamacppStats => {
const promptTokens = getMetricValue(metrics, "llamacpp:prompt_tokens_total");
const cachedTokens = getMetricValue(metrics, "llamacpp:prompt_tokens_cached_total");
const promptCacheHitRate =
promptTokens !== null && cachedTokens !== null && promptTokens + cachedTokens > 0
? Math.round((cachedTokens / (promptTokens + cachedTokens)) * 100)
: null;
return {
health: health.status,
model,
contextUsage,
slots: slotsSummary,
metrics: {
generationSpeedTps: getMetricValue(metrics, "llamacpp:predicted_tokens_seconds"),
promptSpeedTps: getMetricValue(metrics, "llamacpp:prompt_tokens_seconds"),
avgGenerationSpeedTps: avgTokensPerSecond(
getMetricValue(metrics, "llamacpp:tokens_predicted_total"),
getMetricValue(metrics, "llamacpp:tokens_predicted_seconds_total"),
),
avgPromptSpeedTps: avgTokensPerSecond(
getMetricValue(metrics, "llamacpp:prompt_tokens_total"),
getMetricValue(metrics, "llamacpp:prompt_seconds_total"),
),
requestsProcessing: getMetricValue(metrics, "llamacpp:requests_processing"),
requestsDeferred: getMetricValue(metrics, "llamacpp:requests_deferred"),
tokensProcessed: getMetricValue(metrics, "llamacpp:prompt_tokens_total"),
tokensGenerated: getMetricValue(metrics, "llamacpp:tokens_predicted_total"),
promptCacheHitRate,
requestDecodedTokens: perRequest.decodedTokens,
taskId: perRequest.taskId,
specDraftTokens: getMetricValue(metrics, "llamacpp:spec_decode_num_draft_tokens_total"),
specAcceptedTokens: getMetricValue(metrics, "llamacpp:spec_decode_num_accepted_tokens_total"),
specDrafts: getMetricValue(metrics, "llamacpp:spec_decode_num_drafts_total"),
},
};
};
export const parseLlamacppHealthAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<z.infer<typeof llamacppHealthSchema>> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError(LLMACPP_HEALTH_PARSE_ERROR_MESSAGE, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const result = await llamacppHealthSchema.safeParseAsync(json);
if (!result.success) {
throw new ParseError(LLMACPP_HEALTH_PARSE_ERROR_MESSAGE, { cause: result.error });
}
return result.data;
};
export const parseLlamacppModelsAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<z.infer<typeof llamacppModelsSchema>> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError(LLMACPP_MODELS_PARSE_ERROR_MESSAGE, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const result = await llamacppModelsSchema.safeParseAsync(json);
if (!result.success) {
throw new ParseError(LLMACPP_MODELS_PARSE_ERROR_MESSAGE, { cause: result.error });
}
return result.data;
};
export const parseLlamacppMetricsAsync = async (response: {
text: () => Promise<string>;
}): Promise<{ name: string; value: number }[]> => {
let text: string;
try {
text = await response.text();
} catch (error) {
throw new ParseError("Invalid llama.cpp /metrics response", {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
return parsePrometheusMetrics(text);
};
@@ -1,489 +0,0 @@
// @vitest-environment node
import { ParseError } from "@homarr/common/server";
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
import { LlamacppIntegration } from "../llamacpp-integration";
import {
avgTokensPerSecond,
mapContextUsage,
mapLlamacppModel,
mapLlamacppPerRequest,
mapSlotsSummary,
parsePrometheusMetrics,
requestSpeedTps,
} from "../llamacpp-types";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "http://llamacpp.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const sampleModel = {
id: "/root/models/unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
meta: {
n_ctx: 131072,
n_params: 27320697856,
size: 17912397824,
ftype: "Q4_K - Small",
},
};
const sampleModelsResponse = {
data: [sampleModel],
};
const sampleMetricsText = [
"# HELP llamacpp:prompt_tokens_total Total number of tokens processed",
"# TYPE llamacpp:prompt_tokens_total counter",
"llamacpp:prompt_tokens_total 69834",
"llamacpp:prompt_seconds_total 23278",
"llamacpp:prompt_tokens_cached_total 12000",
"llamacpp:tokens_predicted_total 12161",
"llamacpp:tokens_predicted_seconds_total 380.03125",
"llamacpp:requests_processing 1",
"llamacpp:requests_deferred 0",
"llamacpp:predicted_tokens_seconds 32.8472",
"llamacpp:prompt_tokens_seconds 1500.5",
"llamacpp:spec_decode_num_draft_tokens_total 128",
"llamacpp:spec_decode_num_accepted_tokens_total 96",
"llamacpp:spec_decode_num_drafts_total 16",
].join("\n");
const sampleSlotsResponse = [
{
id: 0,
n_ctx: 131072,
n_prompt_tokens: 101076,
is_processing: true,
speculative: false,
id_task: 7,
next_token: [{ n_decoded: 42, n_remain: 100, has_next_token: true, has_new_line: false }],
},
];
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const textResponse = (body: string, status = 200) =>
new Response(body, {
status,
headers: { "content-type": "text/plain" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new LlamacppIntegration({
id: "test-llamacpp",
name: "Test llama.cpp",
url: TEST_URL,
externalUrl: null,
decryptedSecrets,
});
const mockAllEndpoints = () => {
mockFetch.mockReset();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/v1/models")) {
return jsonResponse(sampleModelsResponse);
}
if (url.includes("/metrics")) {
return textResponse(sampleMetricsText);
}
if (url.includes("/slots")) {
return jsonResponse(sampleSlotsResponse);
}
return textResponse("not found", 404);
}) as typeof fetchWithTrustedCertificatesAsync);
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("parsePrometheusMetrics", () => {
test("parses metric lines and skips comments and HELP/TYPE lines", () => {
const metrics = parsePrometheusMetrics(sampleMetricsText);
expect(metrics).toStrictEqual([
{ name: "llamacpp:prompt_tokens_total", value: 69834 },
{ name: "llamacpp:prompt_seconds_total", value: 23278 },
{ name: "llamacpp:prompt_tokens_cached_total", value: 12000 },
{ name: "llamacpp:tokens_predicted_total", value: 12161 },
{ name: "llamacpp:tokens_predicted_seconds_total", value: 380.03125 },
{ name: "llamacpp:requests_processing", value: 1 },
{ name: "llamacpp:requests_deferred", value: 0 },
{ name: "llamacpp:predicted_tokens_seconds", value: 32.8472 },
{ name: "llamacpp:prompt_tokens_seconds", value: 1500.5 },
{ name: "llamacpp:spec_decode_num_draft_tokens_total", value: 128 },
{ name: "llamacpp:spec_decode_num_accepted_tokens_total", value: 96 },
{ name: "llamacpp:spec_decode_num_drafts_total", value: 16 },
]);
});
test("parses metric lines with labels and skips non-numeric values", () => {
const metrics = parsePrometheusMetrics(
[
'llamacpp:requests_by_status{status="ok"} 5',
"llamacpp:uptime_seconds +Inf",
"llamacpp:nan_metric NaN",
"",
].join("\n"),
);
expect(metrics).toStrictEqual([{ name: "llamacpp:requests_by_status", value: 5 }]);
});
test("returns an empty array for empty input", () => {
expect(parsePrometheusMetrics("")).toStrictEqual([]);
});
});
describe("mapLlamacppModel", () => {
test("extracts a short display name and metadata fields", () => {
const model = mapLlamacppModel(sampleModel);
expect(model).toStrictEqual({
id: "/root/models/unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
name: "Qwen3.8-27B-UD-Q4_K_XL",
contextSize: 131072,
parameterCount: 27320697856,
fileSizeBytes: 17912397824,
quantization: "Q4_K - Small",
});
});
test("returns null metadata when meta is missing", () => {
const model = mapLlamacppModel({ id: "models/foo.gguf" });
expect(model).toStrictEqual({
id: "models/foo.gguf",
name: "foo",
contextSize: null,
parameterCount: null,
fileSizeBytes: null,
quantization: null,
});
});
});
describe("mapContextUsage", () => {
test("computes percent from a slot with valid n_ctx and n_prompt_tokens", () => {
expect(mapContextUsage(sampleSlotsResponse)).toStrictEqual({
usedTokens: 101076,
contextSize: 131072,
percent: 77.1,
});
});
test("returns the slot with the greatest used tokens, not the first valid slot", () => {
expect(
mapContextUsage([
{ n_ctx: 1000, n_prompt_tokens: 100 },
{ n_ctx: 8192, n_prompt_tokens: 6000 },
{ n_ctx: 4096, n_prompt_tokens: 3000 },
]),
).toStrictEqual({
usedTokens: 6000,
contextSize: 8192,
percent: 73.2,
});
});
test("returns null for missing, non-array, or malformed slots", () => {
expect(mapContextUsage(null)).toBeNull();
expect(mapContextUsage([{ n_ctx: 0, n_prompt_tokens: 10 }])).toBeNull();
expect(mapContextUsage([{ n_prompt_tokens: 10 }])).toBeNull();
expect(mapContextUsage(["not-an-object", null])).toBeNull();
});
test("clamps the percentage to 100", () => {
expect(mapContextUsage([{ n_ctx: 10, n_prompt_tokens: 50 }])).toStrictEqual({
usedTokens: 50,
contextSize: 10,
percent: 100,
});
});
});
describe("avgTokensPerSecond", () => {
test("divides cumulative counters", () => {
expect(avgTokensPerSecond(12161, 380.03125)).toBe(32);
});
test("returns null when either counter is missing or seconds are zero", () => {
expect(avgTokensPerSecond(null, 5)).toBeNull();
expect(avgTokensPerSecond(10, null)).toBeNull();
expect(avgTokensPerSecond(10, 0)).toBeNull();
});
});
describe("mapLlamacppPerRequest", () => {
test("returns the active request id and decoded tokens from the processing slot", () => {
expect(mapLlamacppPerRequest(sampleSlotsResponse)).toStrictEqual({ taskId: 7, decodedTokens: 42 });
});
test("skips idle slots and returns nulls", () => {
expect(
mapLlamacppPerRequest([{ is_processing: false, id_task: 3, next_token: [{ n_decoded: 10 }] }]),
).toStrictEqual({ taskId: null, decodedTokens: null });
});
test("returns nulls for missing, empty, or malformed slots", () => {
expect(mapLlamacppPerRequest(null)).toStrictEqual({ taskId: null, decodedTokens: null });
expect(mapLlamacppPerRequest([])).toStrictEqual({ taskId: null, decodedTokens: null });
expect(mapLlamacppPerRequest(["nope", null])).toStrictEqual({ taskId: null, decodedTokens: null });
});
test("handles a processing slot missing the per-request fields", () => {
expect(mapLlamacppPerRequest([{ is_processing: true }])).toStrictEqual({
taskId: null,
decodedTokens: null,
});
});
});
describe("mapSlotsSummary", () => {
test("counts slots, processing slots, decoding slots and speculative flags", () => {
expect(
mapSlotsSummary([
{ is_processing: true, next_token: [{ n_decoded: 4 }], speculative: false },
{ is_processing: false, speculative: true },
{ is_processing: true, next_token: [], speculative: false },
]),
).toStrictEqual({ total: 3, processing: 2, decoding: 1, speculative: true });
});
test("treats an empty next_token list as not decoding", () => {
expect(mapSlotsSummary([{ is_processing: true, next_token: [] }, { is_processing: false }])).toStrictEqual({
total: 2,
processing: 1,
decoding: 0,
speculative: null,
});
});
test("returns null for missing, empty, or malformed slot lists", () => {
expect(mapSlotsSummary(null)).toBeNull();
expect(mapSlotsSummary([])).toBeNull();
expect(mapSlotsSummary(["not-an-object", null])).toBeNull();
});
});
describe("requestSpeedTps", () => {
test("divides token delta by elapsed seconds", () => {
expect(requestSpeedTps(128, 4)).toBe(32);
});
test("returns null for missing values, non-positive, or negative elapsed", () => {
expect(requestSpeedTps(null, 4)).toBeNull();
expect(requestSpeedTps(10, null)).toBeNull();
expect(requestSpeedTps(10, 0)).toBeNull();
expect(requestSpeedTps(10, -2)).toBeNull();
});
});
describe("LlamacppIntegration getStatsAsync", () => {
test("fetches health, models, metrics and slots and maps them into stats", async () => {
mockAllEndpoints();
const stats = await createIntegration().getStatsAsync();
expect(stats).toStrictEqual({
health: "ok",
model: {
id: "/root/models/unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
name: "Qwen3.8-27B-UD-Q4_K_XL",
contextSize: 131072,
parameterCount: 27320697856,
fileSizeBytes: 17912397824,
quantization: "Q4_K - Small",
},
contextUsage: {
usedTokens: 101076,
contextSize: 131072,
percent: 77.1,
},
slots: { total: 1, processing: 1, decoding: 1, speculative: false },
metrics: {
generationSpeedTps: 32.8472,
promptSpeedTps: 1500.5,
avgGenerationSpeedTps: 32,
avgPromptSpeedTps: 3,
requestsProcessing: 1,
requestsDeferred: 0,
tokensProcessed: 69834,
tokensGenerated: 12161,
promptCacheHitRate: 15,
requestDecodedTokens: 42,
taskId: 7,
specDraftTokens: 128,
specAcceptedTokens: 96,
specDrafts: 16,
},
});
const requestedUrls = mockFetch.mock.calls.map((call) => String(call[0])).toSorted();
expect(requestedUrls).toStrictEqual(
[`${TEST_URL}/health`, `${TEST_URL}/metrics`, `${TEST_URL}/slots`, `${TEST_URL}/v1/models`].toSorted(),
);
});
test("returns null model when the server has no models loaded", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/v1/models")) {
return jsonResponse({ data: [] });
}
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/slots")) {
return jsonResponse([]);
}
return textResponse(sampleMetricsText);
}) as typeof fetchWithTrustedCertificatesAsync);
const stats = await createIntegration().getStatsAsync();
expect(stats.model).toBeNull();
expect(stats.metrics.generationSpeedTps).toBe(32.8472);
});
test("returns null for metrics that are absent from /metrics", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/v1/models")) {
return jsonResponse({ data: [] });
}
if (url.includes("/slots")) {
return jsonResponse([]);
}
return textResponse("");
}) as typeof fetchWithTrustedCertificatesAsync);
const stats = await createIntegration().getStatsAsync();
expect(stats.metrics).toStrictEqual({
generationSpeedTps: null,
promptSpeedTps: null,
avgGenerationSpeedTps: null,
avgPromptSpeedTps: null,
requestsProcessing: null,
requestsDeferred: null,
tokensProcessed: null,
tokensGenerated: null,
promptCacheHitRate: null,
requestDecodedTokens: null,
taskId: null,
specDraftTokens: null,
specAcceptedTokens: null,
specDrafts: null,
});
});
test("throws when the /health endpoint returns an error status", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/health")) {
return textResponse("Service Unavailable", 503);
}
return jsonResponse({ status: "ok" });
}) as typeof fetchWithTrustedCertificatesAsync);
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
});
test("degrades to null metrics when /metrics is not available (501) but keeps health and model data", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/metrics")) {
return textResponse("Not Implemented", 501);
}
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/v1/models")) {
return jsonResponse(sampleModelsResponse);
}
if (url.includes("/slots")) {
return jsonResponse(sampleSlotsResponse);
}
return textResponse("not found", 404);
}) as typeof fetchWithTrustedCertificatesAsync);
const stats = await createIntegration().getStatsAsync();
expect(stats.health).toBe("ok");
expect(stats.model?.name).toBe("Qwen3.8-27B-UD-Q4_K_XL");
expect(stats.contextUsage).toStrictEqual({
usedTokens: 101076,
contextSize: 131072,
percent: 77.1,
});
expect(stats.slots).toStrictEqual({ total: 1, processing: 1, decoding: 1, speculative: false });
expect(stats.metrics).toStrictEqual({
generationSpeedTps: null,
promptSpeedTps: null,
avgGenerationSpeedTps: null,
avgPromptSpeedTps: null,
requestsProcessing: null,
requestsDeferred: null,
tokensProcessed: null,
tokensGenerated: null,
promptCacheHitRate: null,
requestDecodedTokens: 42,
taskId: 7,
specDraftTokens: null,
specAcceptedTokens: null,
specDrafts: null,
});
});
test("throws a parse error when /v1/models is not valid JSON", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/v1/models")) {
return textResponse("not-json");
}
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/slots")) {
return jsonResponse([]);
}
return textResponse(sampleMetricsText);
}) as typeof fetchWithTrustedCertificatesAsync);
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
if (!(error instanceof IntegrationParseError)) return false;
const cause = error.cause;
return cause instanceof ParseError && cause.message.includes("Invalid llama.cpp /v1/models response");
});
});
});
@@ -11,7 +11,6 @@ import type { TracearrDashboardData } from "../tracearr/tracearr-types";
import type { TraefikDashboardData } from "../traefik/traefik-types";
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats, UmamiWebsite } from "../umami/umami-types";
import type { UptimeKumaDashboardData } from "../uptime-kuma/uptime-kuma-types";
import type { LlamacppStats } from "../llama-cpp/llamacpp-types";
import type { WudStats } from "../wud/wud-types";
import type {
FirewallCpuSummary,
@@ -353,33 +352,4 @@ export const mockWidgetData = {
{ id: "redis", name: "Redis", currentVersion: "8.0.2", newVersion: "8.0.3", link: null },
],
} satisfies WudStats,
llamacpp: {
health: "ok",
model: {
id: "unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
name: "Qwen3.8-27B-UD-Q4_K_XL",
contextSize: 32768,
parameterCount: 27_347_098_624,
fileSizeBytes: 16_842_776_576,
quantization: "Q4_K_XL",
},
contextUsage: { usedTokens: 8192, contextSize: 32768, percent: 25 },
slots: { total: 2, processing: 1, decoding: 1, speculative: false },
metrics: {
generationSpeedTps: 42.5,
promptSpeedTps: 1840.2,
avgGenerationSpeedTps: 39.8,
avgPromptSpeedTps: 2104.6,
requestsProcessing: 1,
requestsDeferred: 0,
tokensProcessed: 12_483,
tokensGenerated: 1_024,
promptCacheHitRate: 62,
requestDecodedTokens: 96,
taskId: 1042,
specDraftTokens: 48,
specAcceptedTokens: 12,
specDrafts: 8,
},
} satisfies LlamacppStats,
} as const;
@@ -67,12 +67,10 @@ const ws = vi.hoisted(() => {
void Promise.resolve(responder(payload.method as string, (payload.params as unknown[] | undefined) ?? [])).then(
(result) => {
// An Error mimics a rejected method; promises support independent-request checks.
const body = result instanceof Error ? { error: { message: result.message } } : { result };
const envelope =
payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, ...body }
: { msg: "result", id: payload.id, ...body };
? { jsonrpc: "2.0", id: payload.id, result }
: { msg: "result", id: payload.id, result };
this.respond(JSON.stringify(envelope));
},
);
@@ -103,10 +101,7 @@ const ws = vi.hoisted(() => {
};
});
const logs = vi.hoisted(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }));
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => logs }));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
@@ -163,14 +158,6 @@ const happyResponder = (method: string) => {
];
case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
case "pool.dataset.query":
return [
{
id: "tank",
used: { parsed: 200 },
available: { parsed: 300 },
},
];
case "interface.query":
return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data":
@@ -182,11 +169,6 @@ const happyResponder = (method: string) => {
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
// `pool.query` reports 500 allocated of a 1000 byte pool, leaving 500 free.
const physicalFallbackFileSystem = [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }];
const ERROR_MESSAGE_LIMIT = 200;
let integrationCounter = 0;
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
new TrueNasIntegration({
@@ -208,7 +190,6 @@ describe("TrueNasIntegration", () => {
ws.constructedUrls.length = 0;
ws.failures.length = 0;
ws.setResponder(happyResponder);
logs.warn.mockClear();
});
test("fetches and maps system info over the JSON-RPC API", async () => {
@@ -220,18 +201,6 @@ describe("TrueNasIntegration", () => {
// JSON-RPC does not use the legacy "connect" session handshake.
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
const datasetPayload = ws.sentPayloads.find((payload) => payload.method === "pool.dataset.query");
expect(datasetPayload).toMatchObject({
params: [
[["id", "in", ["tank"]]],
{
extra: {
properties: ["used", "available"],
},
},
],
});
expect(result).toMatchObject({
version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon",
@@ -245,131 +214,12 @@ describe("TrueNasIntegration", () => {
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
// `available` and `used` come from the root dataset, not the physical pool.
fileSystem: [{ deviceName: "tank", available: "300", used: "200", percentage: 40 }],
// `available` is the free space left on the pool (free = 500), not its total size.
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
});
test("falls back to physical pool space when the root dataset is not returned", async () => {
ws.setResponder((method) => (method === "pool.dataset.query" ? [] : happyResponder(method)));
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// Dropping the pool would also hide its health status.
expect(result.smart).toMatchObject([{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" }]);
});
test("falls back to physical pool space when the root dataset reports no usable values", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? [{ id: "tank", used: { parsed: null }, available: null }]
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
});
test("falls back to physical pool space when the root dataset omits the requested properties", async () => {
// Datasets that cannot report space, e.g. while locked, come back without `used` and `available`.
ws.setResponder((method) =>
method === "pool.dataset.query" ? [{ id: "tank", locked: true }] : happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
});
test("falls back to physical pool space when the dataset response fails schema validation", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query" ? { unexpected: "not an array" } : happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// A malformed payload must not take the rest of the system info down with it.
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
});
test("falls back to physical pool space when the dataset query is rejected", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? new Error("[EINVAL] filters: Value error, Invalid operation: in")
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// A rejected dataset query must not take the rest of the system info down with it.
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
});
test("logs the truncated rejection reason rather than the generic wrapper message", async () => {
// TrueNAS answers a rejected method with its Python traceback, which runs to several kilobytes.
ws.setResponder((method) =>
method === "pool.dataset.query"
? new Error(`[EINVAL] query-filters ${"x".repeat(5000)}`)
: happyResponder(method),
);
const integration = createIntegration(credentials);
await integration.getSystemInfoAsync();
const logged = logs.warn.mock.calls.find(([message]) => String(message).includes("Could not retrieve"));
const reason = String((logged?.[1] as { error?: string } | undefined)?.error);
// The decorator would otherwise surface only "An unknown error occured while executing Integration method".
expect(reason).toContain("[EINVAL] query-filters");
expect(reason).toHaveLength(ERROR_MESSAGE_LIMIT + 1); // truncated, plus the ellipsis
});
test("keeps reporting a pool that is unhealthy but has no usable dataset space", async () => {
ws.setResponder((method) => {
if (method === "pool.query")
return [
{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 },
{ name: "backup", status: "DEGRADED", healthy: false, free: 10, size: 100, allocated: 90 },
];
if (method === "pool.dataset.query") return [{ id: "tank", used: { parsed: 200 }, available: { parsed: 300 } }];
return happyResponder(method);
});
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.smart).toMatchObject([
{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" },
{ deviceName: "backup", healthy: false, overallStatus: "DEGRADED" },
]);
expect(result.fileSystem).toMatchObject([
{ deviceName: "tank", available: "300", used: "200", percentage: 40 },
{ deviceName: "backup", available: "10", used: "90", percentage: 90 },
]);
});
test("reports zero usage for an empty root dataset", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? [{ id: "tank", used: { parsed: 0 }, available: { parsed: 0 } }]
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem[0]?.percentage).toBe(0);
});
test("starts independent system requests together while preserving the network interface dependency", async () => {
const gates = new Map<string, ReturnType<typeof Promise.withResolvers<unknown>>>();
ws.setResponder((method) => {
@@ -386,14 +236,10 @@ describe("TrueNasIntegration", () => {
expect([...gates.keys()]).toEqual(["system.info", "reporting.get_data", "pool.query", "interface.query"]);
});
expect(gates.has("reporting.netdata_get_data")).toBe(false);
expect(gates.has("pool.dataset.query")).toBe(false);
gates.get("interface.query")?.resolve(happyResponder("interface.query"));
await vi.waitFor(() => expect(gates.has("reporting.netdata_get_data")).toBe(true));
gates.get("pool.query")?.resolve(happyResponder("pool.query"));
await vi.waitFor(() => expect(gates.has("pool.dataset.query")).toBe(true));
for (const [method, gate] of gates) gate.resolve(happyResponder(method));
await expect(resultPromise).resolves.toMatchObject({
@@ -15,22 +15,6 @@ const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100;
const ERROR_MESSAGE_LIMIT = 200;
/**
* The error decorator replaces a rejected request with a generic message and moves the actionable
* reason to `cause`. TrueNAS puts its middleware traceback in there, several kilobytes of it, so the
* reason is truncated before it reaches a log line that repeats on every poll.
*/
const describeRequestError = (error: unknown) => {
const cause = error instanceof Error ? error.cause : undefined;
let reason = String(error);
if (error instanceof Error) reason = error.message;
if (cause instanceof Error) reason = cause.message;
if (reason.length > ERROR_MESSAGE_LIMIT) return `${reason.slice(0, ERROR_MESSAGE_LIMIT)}…`;
return reason;
};
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private client?: TrueNasClient;
@@ -64,17 +48,12 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
memAvailableInBytes,
memUsedInBytes,
fileSystem: datasets.map((dataset) => {
const total = dataset.used + dataset.available;
let percentage = 0;
if (total > 0) percentage = (dataset.used / total) * 100;
return {
deviceName: dataset.name,
available: `${dataset.available}`,
used: `${dataset.used}`,
percentage,
};
}),
fileSystem: datasets.map((dataset) => ({
deviceName: dataset.name,
available: `${dataset.free}`, // free space left on the pool
used: `${dataset.allocated}`,
percentage: (dataset.allocated / dataset.size) * 100,
})),
availablePkgUpdates: 0,
network: {
up: upload * NETWORK_MULTIPLIER,
@@ -129,10 +108,9 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
name: pool.name,
status: pool.status,
healthy: pool.healthy,
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
@@ -140,76 +118,12 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
});
if (activePools.length === 0) return [];
const usableSpaceByPool = await this.getUsableSpaceByPoolAsync(activePools.map((pool) => pool.name));
return activePools.map((pool) => {
const usableSpace = usableSpaceByPool.get(pool.name);
if (!usableSpace) {
logger.warn("Falling back to physical pool space, the root dataset reported no usable space", {
url: this.integration.url,
pool: pool.name,
});
}
return {
name: pool.name,
status: pool.status,
healthy: pool.healthy,
used: usableSpace?.used ?? pool.allocated,
available: usableSpace?.available ?? pool.free,
};
});
}
/**
* Returns the usable space of each pool's root ZFS dataset, keyed by pool name. Unlike the physical
* `pool.query` values, these account for RAIDZ parity overhead and dataset limits, matching `zfs list`.
* See https://github.com/homarr-labs/homarr/issues/6566
*
* A pool is left out when its usable space is unavailable, so the caller keeps reporting the pool with
* its physical values instead of dropping it, which would also hide its health status:
* - request rejected → method or `extra.properties` unsupported, or the API key lacks the dataset role
* - response rejected → the payload does not match the expected shape
* - dataset missing → the pool's root dataset was not returned by the query
* - property absent → the dataset does not report `used` / `available`, e.g. while locked
*/
private async getUsableSpaceByPoolAsync(poolNames: string[]) {
const usableSpaceByPool = new Map<string, { used: number; available: number }>();
try {
const datasets = await this.requestAsync("pool.dataset.query", [
[["id", "in", poolNames]],
{
extra: {
properties: ["used", "available"],
},
},
]);
for (const dataset of await poolDatasetSchema.parseAsync(datasets)) {
const used = dataset.used?.parsed;
const available = dataset.available?.parsed;
if (typeof used !== "number" || typeof available !== "number") continue;
usableSpaceByPool.set(dataset.id, { used, available });
}
} catch (error) {
logger.warn("Could not retrieve root dataset space, continuing with physical pool space", {
url: this.integration.url,
error: describeRequestError(error),
});
}
return usableSpaceByPool;
return activePools;
}
/**
@@ -350,19 +264,6 @@ const poolSchema = z.array(
}),
);
// Properties are absent or unparsed for datasets that do not report them, e.g. while locked,
// so they are optional here and the pool falls back to its physical values instead of failing
// the whole request, which would also drop cpu, memory and network data
const poolDatasetPropertySchema = z.object({ parsed: z.number().min(0).nullish() }).nullish();
const poolDatasetSchema = z.array(
z.object({
id: z.string(),
used: poolDatasetPropertySchema,
available: poolDatasetPropertySchema,
}),
);
const reportingNetDataSchema = z.array(
z.object({
name: z.string(),
@@ -37,11 +37,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
const systemInfo = await this.getSystemInformationAsync();
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
const cpuCount = systemInfo.metrics.cpu.cpus.length;
let cpuUtilizationNormalized = 0;
if (cpuCount > 0) {
cpuUtilizationNormalized = cpuUtilization / cpuCount;
}
const cpuCount = systemInfo.info.cpu.cores;
const totalMemory = systemInfo.metrics.memory.total;
const usedMemory = Math.max(totalMemory - systemInfo.metrics.memory.available, 0);
@@ -50,7 +46,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
return {
version: systemInfo.info.os.release,
cpuModelName: systemInfo.info.cpu.brand,
cpuUtilization: cpuUtilizationNormalized,
cpuUtilization: cpuUtilization / cpuCount,
memUsedInBytes: usedMemory,
memAvailableInBytes: totalMemory - usedMemory,
uptime: dayjs().diff(uptime, "seconds"),
@@ -1,48 +0,0 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { HomeAssistantIntegration } from "../src";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new HomeAssistantIntegration({
id: "home-assistant",
name: "Home Assistant",
url: "http://home-assistant.local:8123",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "secret" }],
});
describe("HomeAssistantIntegration actions", () => {
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue({ ok: true } as never);
});
test.each([
["toggle", () => integration.triggerToggleAsync("light.desk"), "/api/services/homeassistant/toggle", "light.desk"],
[
"automation",
() => integration.triggerAutomationAsync("automation.turn_on_lights"),
"/api/services/automation/trigger",
"automation.turn_on_lights",
],
])("sends the %s service call as JSON", async (_action, trigger, path, entityId) => {
await expect(trigger()).resolves.toBe(true);
expect(mockFetch).toHaveBeenCalledWith(new URL(`http://home-assistant.local:8123${path}`), {
headers: {
Authorization: "Bearer secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ entity_id: entityId }),
method: "POST",
});
});
});
@@ -1,39 +0,0 @@
// @vitest-environment node
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { getServerStatistics, init } from "@immich/sdk";
import { describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn(async () => []),
getTrustedCertificateHostnamesAsync: vi.fn(async () => []),
}));
describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync", () => {
test("x-api-key set via init() reaches the server", async () => {
let receivedXApiKey: string | string[] | undefined;
const server = createServer((req, res) => {
receivedXApiKey = req.headers["x-api-key"];
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ usage: 1000, photos: 5, videos: 2 }));
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const port = (server.address() as AddressInfo).port;
init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" });
try {
const stats = await getServerStatistics({
fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch,
});
expect(receivedXApiKey).toBe("sdk-configured-key");
expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 });
} finally {
server.close();
}
});
});
-16
View File
@@ -1,16 +0,0 @@
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { LlamacppStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const llamacppStatsRequestHandler = createIntegrationRequestHandler<
LlamacppStats,
"llamacpp",
Record<string, never>
>({
cacheNamespace: "llamacpp:stats",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
},
});
@@ -38,6 +38,4 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
return result.data;
},
// The widget already polls every 30 seconds and refetches after toggles.
cacheTtlMs: 0,
});
File diff suppressed because it is too large. Load diff
+146 -272
View File
@@ -1018,17 +1018,16 @@
"releasesProvider": "发布",
"notifications": "通知",
"firewall": "防火墙",
"timetable": "时间表",
"timetable": "时刻表",
"photoService": "照片",
"documents": "文档",
"notes": "笔记",
"mediaMonitoring": "媒体监控",
"mediaMonitoring": "监控",
"speedtest": "速度测试",
"analytics": "分析",
"vpn": "VPN",
"mediaLibrary": "媒体库",
"uptimeMonitoring": "运行时间监控",
"systemMonitoring": "系统监控"
"uptimeMonitoring": "运行时间监控"
},
"testConnection": {
"action": {
@@ -1311,8 +1310,8 @@
"newLabel": "新建私钥"
},
"slug": {
"label": "状态页标识",
"newLabel": "新状态页标识"
"label": "状态页面标识",
"newLabel": "新的状态页面标识"
}
}
},
@@ -1323,7 +1322,7 @@
},
"audiobookshelf": {
"name": "Audiobookshelf",
"description": "自托管有声读物和播客服务器"
"description": "自托管有声书和播客服务器"
},
"navidrome": {
"name": "Navidrome",
@@ -1331,13 +1330,13 @@
},
"uptimeKuma": {
"name": "Uptime Kuma",
"description": "自托管运行时间监控,支持公开状态页",
"description": "自托管运行时间监控,带有公开状态页",
"secret": {
"slug": {
"description": "您公开状态页的标识。如未设置,默认值为“default”。"
"description": "公开状态页的标识。如果未设置,默认为 \"default\"。"
},
"apiKey": {
"description": "用于访问 /metrics 认证接口的可选 API 密钥"
"description": "用于经过身份验证的 /metrics 访问的可选 API 密钥"
}
}
},
@@ -1347,7 +1346,7 @@
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "用于归档和整理扫描文档的文档管理系统"
"description": "用于归档和组织扫描文档的文档管理系统"
},
"patchmon": {
"name": "PatchMon",
@@ -1356,14 +1355,6 @@
"bazarr": {
"name": "Bazarr",
"description": "Sonarr 和 Radarr 的配套应用,用于管理和下载字幕"
},
"llamacpp": {
"name": "llama.cpp",
"description": "监控本地 llama.cpp llama-server:健康状态、模型与生成指标"
},
"wud": {
"name": "What's Up Docker",
"description": "监控您哪些 Docker 容器有可用的镜像更新"
}
},
"media": {
@@ -1407,7 +1398,7 @@
}
},
"noResults": {
"title": "暂无上传的媒体"
"title": "未上传媒体"
}
},
"common": {
@@ -1446,20 +1437,10 @@
"here": "这里",
"iconPicker": {
"label": "图标 URL",
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标",
"headerLoading": "按名称搜索图像",
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。",
"headerLoading": "输入以搜索图标…",
"noResults": "没有与“{search}”匹配的图标。",
"error": "无法加载图标。",
"description": "搜索库,粘贴图片URL,或上传文件。",
"clear": "清除图像",
"uploadImage": "上传图片",
"results": "图像搜索结果",
"localImages": "你的映像",
"svgIcons": "SVG 图标",
"otherImages": "其他映像",
"urlLoading": "正在检查图片…",
"urlReady": "图像已准备好使用",
"urlError": "无法加载此图像。请检查URL,然后重试。"
"error": "无法加载图标。"
},
"colorScheme": {
"options": {
@@ -1682,7 +1663,7 @@
"app": "编辑应用"
},
"app": {
"propagationNotice": "对此应用的更改将应用于所有面板中使用该应用的位置。"
"propagationNotice": "对此应用的更改将应用于所有面板中使用它的所有位置。"
},
"advancedOptions": {
"label": "高级选项",
@@ -2505,7 +2486,7 @@
},
"ups": {
"name": "UPS",
"description": "通过 NUT 服务器监控您的 UPS 设备状态",
"description": "通过 NUT 服务器监控 UPS 设备的状态",
"option": {
"showBattery": {
"label": "显示电量"
@@ -3803,35 +3784,35 @@
"option": {
"showArtists": {
"label": "显示艺术家",
"description": "显示音乐库中的艺术家总数(Navidrome)"
"description": "显示音乐库中艺术家的总数 (Navidrome)"
},
"showAlbums": {
"label": "显示专辑",
"description": "显示音乐库中的专辑总数(Navidrome)"
"description": "显示音乐库中专辑的总数 (Navidrome)"
},
"showSongs": {
"label": "显示歌曲",
"description": "显示音乐库中的歌曲总数(Navidrome)"
"description": "显示音乐库中歌曲的总数 (Navidrome)"
},
"showLibraryCount": {
"label": "显示媒体库数量",
"description": "显示实例中的媒体库数量(Audiobookshelf)"
"label": "显示库数量",
"description": "显示实例中的库数量 (Audiobookshelf)"
},
"showAudiobooks": {
"label": "显示有声读物",
"description": "显示所有媒体库中的有声读物总数(Audiobookshelf)"
"label": "显示有声书",
"description": "显示所有库中的有声书总数 (Audiobookshelf)"
},
"showPodcasts": {
"label": "显示播客",
"description": "显示所有媒体库中的播客总数(Audiobookshelf)"
"description": "显示所有库中的播客总数 (Audiobookshelf)"
},
"showListeningTime": {
"label": "显示收听时长",
"description": "显示总收听时长(Audiobookshelf)"
"label": "显示收听时间",
"description": "显示您的总收听时间 (Audiobookshelf)"
},
"showActiveSessions": {
"label": "显示活跃会话",
"description": "显示当前活跃播放会话的数量(Audiobookshelf)"
"description": "显示当前活跃的播放会话数量 (Audiobookshelf)"
},
"compactMode": {
"label": "紧凑模式",
@@ -3841,8 +3822,8 @@
"artists": "艺术家",
"albums": "专辑",
"songs": "歌曲",
"libraryCount": "媒体库",
"audiobooks": "有声读物",
"libraryCount": "库",
"audiobooks": "有声书",
"podcasts": "播客",
"listeningTime": "收听时长",
"activeSessions": "活跃会话",
@@ -3942,10 +3923,10 @@
},
"beszelSystemTable": {
"name": "Beszel 系统(表格)",
"description": "所有 Beszel 监控系统的表格视图,包含可排序列和状态指示器",
"description": "所有 Beszel 监控系统的表格视图,具有可排序列和状态指示器",
"option": {
"statusFilter": {
"label": "状态筛选"
"label": "状态过滤器"
},
"sortBy": {
"label": "排序方式"
@@ -4076,17 +4057,17 @@
}
},
"beszelAlerts": {
"name": "Beszel 告警",
"description": "查看 Beszel 告警配置和历史记录",
"name": "Beszel 警报",
"description": "查看 Beszel 警报配置和历史记录",
"option": {
"showHistory": {
"label": "显示告警历史"
"label": "显示警报历史"
},
"maxHistoryItems": {
"label": "最大历史记录条数"
}
},
"empty": "未配置告警",
"empty": "未配置警报",
"error": {
"internalServerError": "无法连接到 Beszel。请检查您的集成设置。"
},
@@ -4098,15 +4079,15 @@
}
},
"beszelSystemStats": {
"name": "Beszel 系统统计",
"description": "CPU、内存、磁盘、网络和 Docker 容器指标的时序图表",
"name": "Beszel 系统状态",
"description": "CPU、内存、磁盘、网络和 Docker 容器指标的时间序列图表",
"option": {
"systemId": {
"label": "系统",
"description": "选择要显示统计信息的 Beszel 系统"
},
"timePeriod": {
"label": "时间范围"
"label": "时间段"
},
"showCpu": {
"label": "显示 CPU 图表"
@@ -4146,12 +4127,12 @@
"chart": {
"cpu": {
"title": "CPU 使用率",
"subtitle": "系统整体 CPU 平均使用率",
"subtitle": "平均系统 CPU 利用率",
"series": "CPU"
},
"memory": {
"title": "内存使用率",
"subtitle": "记录时间点的精确使用率",
"subtitle": "记录时的精确利用率",
"series": "已用",
"cache": "缓存/缓冲区"
},
@@ -4173,130 +4154,23 @@
},
"network": {
"title": "带宽",
"subtitle": "公网接口的网络流量",
"sent": "发送",
"subtitle": "公共接口的网络流量",
"sent": "已发送",
"recv": "已接收"
},
"dockerCpu": {
"title": "Docker CPU 使用率",
"subtitle": "容器的平均 CPU 使用率"
"subtitle": "容器的平均 CPU 利用率"
},
"dockerMemory": {
"title": "Docker 内存使用率",
"subtitle": "Docker 容器的内存使用率"
"subtitle": "Docker 容器的内存使用"
},
"dockerNetwork": {
"title": "Docker 网络 I/O",
"subtitle": "Docker 容器的网络流量"
}
}
},
"llamacpp": {
"name": "llama.cpp",
"description": "显示本地 llama.cpp 服务器的健康状态、加载模型和生成速度。",
"title": "llama.cpp",
"status": {
"unhealthy": "异常 ({status})"
},
"idle": "空闲",
"speedTooltip": {
"current": "当前生成速度",
"average": "启动以来的平均生成速度",
"request": "本次请求开始以来的平均速度"
},
"busy": "{count, plural, other {# 个请求处理中}}",
"modelInfo": {
"context": "上下文",
"size": "文件大小",
"parameters": "参数量",
"model": "模型",
"quantization": "量化"
},
"stats": {
"tokensProcessed": "已处理 Token",
"contextUsage": "上下文 / KV 占用",
"cacheHitRate": "提示缓存命中率",
"tokensGenerated": "已生成 Token"
},
"error": {
"unreachable": "无法连接 llama-server"
},
"unit": {
"speed": "{value} t/s",
"parameters": "{value}B",
"none": "—",
"speedShort": "t/s"
},
"option": {
"showTitle": {
"label": "显示标题"
},
"showModelInfo": {
"label": "显示模型信息"
},
"showContextUsage": {
"label": "显示上下文占用"
},
"showCacheHitRate": {
"label": "显示缓存命中率"
},
"showSpeedStats": {
"label": "显示速度统计"
},
"showRequests": {
"label": "显示请求负载"
},
"showTokenStats": {
"label": "显示 Token 总量"
},
"showSpeculative": {
"label": "显示投机解码"
}
},
"advanced": {
"promptSpeed": "Prompt 速度",
"avgSpeed": "平均生成速度",
"requests": "请求数",
"deferred": "排队中",
"slots": "Slot 占用 {processing}/{total}",
"speculative": "投机解码",
"avgPrompt": "平均 Prompt 速度",
"specDrafts": "投机草稿数",
"specAccepted": "投机接受 Token",
"specDraftTokens": "投机草稿 Token"
}
},
"wud": {
"name": "What's Up Docker",
"description": "显示有多少个已监控的容器有可用更新。",
"title": "What's Up Docker",
"monitored": "已监控",
"updatesAvailable": "{count, plural, one {# 个更新可用} other {# 个更新可用}}",
"updateAvailable": "有可用更新",
"openReleaseNotes": "打开发布说明",
"option": {
"showTitle": {
"label": "显示标题"
},
"showRing": {
"label": "显示更新进度环"
},
"layout": {
"label": "布局",
"option": {
"horizontal": {
"label": "水平"
},
"vertical": {
"label": "垂直"
}
}
},
"showUpdateList": {
"label": "显示容器更新列表",
"description": "列出每个有镜像更新可用的已监控容器"
}
}
}
},
"widgetPreview": {
@@ -4698,7 +4572,7 @@
"docker": "Docker",
"kubernetes": "Kubernetes",
"logs": "日志",
"api": "API 与 MCP",
"api": "API",
"certificates": "证书",
"tasks": "任务",
"backup": "备份"
@@ -4816,19 +4690,19 @@
"firstDayOfWeek": "一周的第一天",
"accessibility": "无障碍服务",
"onboardingTours": {
"title": "新手指南",
"description": "重置新手指南,以便您下次访问管理区域或面板时再次看到它们。",
"reset": "重置新手指南",
"title": "入门指南",
"description": "重置入门指南,以便在下次访问管理中心或面板时再次看到它们。",
"reset": "重置入门指南",
"resetSuccess": {
"title": "新手指南已重置",
"message": "新手指南将在您下次访问时再次显示"
"title": "指南已重置",
"message": "入门指南将在您下次访问时再次显示"
},
"status": {
"completed": "已完成",
"notStarted": "未开始"
},
"name": {
"management": "管理",
"management": "管理中心",
"dashboard": "面板"
}
}
@@ -4955,7 +4829,7 @@
"analytics": {
"title": "分析",
"general": {
"title": "协助我们改进 Homarr",
"title": "发送匿名分析",
"text": "这有助于我们了解用户实际使用哪些功能,从而决定将开发精力投入到哪里。我们会收集诸如您拥有多少小组件或集成、Homarr 版本以及使用的数据库类型等信息。仅此而已——不包含个人信息、URL、名称,也不含任何可能识别您本人或您个人配置的内容。"
}
},
@@ -5159,84 +5033,84 @@
"mcp": {
"label": "AI / MCP",
"title": "AI 集成",
"subtitle": "模型上下文协议(MCP)服务器",
"description": "将 AI 助手连接到您的 Homarr 实例,通过自然语言控制您的整个家庭实验室。在 Overseerr 上搜索和请求媒体资源,管理 Docker 容器,切换 Pi-hole,检查服务器健康状态,控制 Home Assistant 设备等——全部通过您的 AI 助手完成。",
"noApiKey": "您需要 API 密钥才能通过 MCP 服务器进行认证。",
"subtitle": "模型上下文协议 (MCP) 服务器",
"description": "将 AI 助手连接到您的 Homarr 实例,通过自然语言控制整个家庭实验室。在 Overseerr 上搜索和请求媒体,管理 Docker 容器,切换 Pi-hole,检查服务器健康状况,控制 Home Assistant 设备等——一切通过您的 AI 助手完成。",
"noApiKey": "您需要一个 API 密钥来与 MCP 服务器进行身份验证。",
"createApiKeyButton": "创建 API 密钥",
"endpoint": {
"title": "MCP 端点"
},
"authentication": {
"title": "认证",
"intro": "您可以使用 API 密钥或 OAuth 对 MCP 服务器进行认证。两种方法授予的访问权限级别相同。",
"intro": "您可以使用 API 密钥或 OAuth 与 MCP 服务器进行身份验证。两种方法授予相同的访问级别。",
"apiKey": {
"title": "API 密钥(手动)",
"description": "在 MCP 客户端配置中通过 ApiKey 标头传递您的完整 API 密钥。"
"title": "API 密钥 (手动)",
"description": "在 MCP 客户端配置中传递带有完整 API 密钥的 ApiKey 标头。"
},
"oauth": {
"title": "OAuth(浏览器登录)",
"description": "支持 OAuth 的客户端(如 Claude Code、opencode 或 Cursor)可通过打开浏览器窗口进行认证。在客户端中运行 auth 命令,在浏览器中登录 Homarr,即可完成连接——API 密钥将自动创建。",
"hint": "OAuth 令牌在“认证”标签页中显示为常规 API 密钥。您可以随时撤销它们。"
"title": "OAuth (浏览器登录)",
"description": "支持 OAuth 的客户端(如 Claude Code、opencode 或 Cursor)可以通过打开浏览器窗口进行身份验证。在客户端中运行 auth 命令,在浏览器中登录 Homarr,即可连接——API 密钥会自动创建。",
"hint": "OAuth 令牌在身份验证选项卡中显示为常规 API 密钥。您可以随时撤销它们。"
},
"permissions": "PI 密钥决定了 AI 可用的权限——它的访问级别与密钥所有者相同。"
"permissions": "API 密钥决定了 AI 可用的权限——它与密钥所有者具有相同的访问级别。"
},
"clientConfig": {
"title": "客户端配置",
"streamableHttp": {
"recommended": "推荐",
"title": "可流式传输的 HTTP",
"worksWith": "适用于:Cursor、Claude Desktop、VS Code Copilot、Windsurf、Claude Code",
"addToConfig": "添加到您的 MCP 客户端配置文件中:",
"cursor": "{name}:项目或全局设置中的 {file}",
"title": "可流式 HTTP",
"worksWith": "适用于: Cursor, Claude Desktop, VS Code Copilot, Windsurf, Claude Code",
"addToConfig": "添加到您的 MCP 客户端配置文件:",
"cursor": "{name}: 项目或全局设置中的 {file}",
"claudeDesktop": "{name}: {file}",
"claudeCode": "{name}:{file} 或使用 {command}",
"vsCodeCopilot": "{name}:工作区设置中的 {file}",
"claudeCode": "{name}: {file} 或使用 {command}",
"vsCodeCopilot": "{name}: 工作区设置中的 {file}",
"replaceHint": "将占位符替换为您在创建时收到的 API 密钥。"
},
"stdio": {
"title": "stdio(通过 mcp-remote 桥接的旧版客户端)",
"description": "适用于仅支持 stdio 传输的客户端。需要本地安装 {dependency}(Node.js):"
"title": "stdio (通过 mcp-remote 桥接的旧客户端)",
"description": "仅支持 stdio 传输的客户端。需要本地安装 {dependency} (Node.js):"
}
},
"testConnection": {
"title": "测试您的连接",
"description": "在终端中运行以下命令,以验证端点可访问且您的 API 密钥有效:",
"hint": "成功响应将返回 MCP 服务器的能力信息。如果看到错误,请检查 JSON 响应中的提示。"
"description": "在终端中运行此命令以验证端点是否可达以及 API 密钥是否有效:",
"hint": "成功响应将返回 MCP 服务器功能。如果出现错误,请检查 JSON 响应以获取提示。"
},
"availableTools": {
"title": "可用工具",
"description": "{count} 个工具可用。展开分类可查看各个工具及其描述。",
"description": "{count} 个工具可用。展开类别以查看各个工具及其描述。",
"columnType": "类型",
"columnName": "工具",
"columnDescription": "描述"
},
"troubleshooting": {
"title": "故障排查",
"title": "故障排除",
"missingHeader": {
"title": "“缺少 ApiKey 标头”",
"description": "您的客户端未发送 {header} 标头。请检查 MCP 配置中的 {section} 部分是否正确。某些客户端在更改配置后需要重启。"
"title": "\"缺少 ApiKey 标头\"",
"description": "您的客户端未发送 {header} 标头。验证 MCP 配置中的 {section} 部分是否正确。某些客户端在配置更改后需要重启。"
},
"invalidFormat": {
"title": "“ApiKey 格式无效”",
"description": "请确保您复制了完整的 API 密钥,而非仅复制了其中一部分。"
"title": "\"无效的 ApiKey 格式\"",
"description": "确保您复制了完整的 API 密钥,而不仅仅是部分。"
},
"invalidKey": {
"title": "“API 密钥无效”",
"description": "未找到该密钥 ID,或令牌有误。API 密钥仅在创建时可见一次。如已丢失,请删除旧密钥并创建新密钥。"
"title": "\"无效的 API 密钥\"",
"description": "找不到密钥 ID 或令牌错误。API 密钥仅在创建时可见一次。如果您丢失了它,请删除旧密钥并创建新密钥。"
},
"connectionRefused": {
"title": "连接被拒绝 / 超时",
"description": "请确保您的 AI 客户端能够访问该 URL。如果 Homarr 运行在反向代理后或 Docker 中,请使用外部 URL(而非 {localhost})。请确认端口是否正确(Docker 默认端口为 7575)。"
"description": "确保 URL 可从您的 AI 客户端访问。如果 Homarr 在反向代理或 Docker 后运行,请使用外部 URL (不是 {localhost})。验证端口是否正确 (默认: Docker 为 7575)。"
},
"toolsNotAppearing": {
"title": "工具未显示",
"description": "部分工具需要先配置特定的集成(例如 Docker、Overseerr)。工具列表取决于您的 Homarr 实例中可用的功能。"
"title": "工具未出现",
"description": "某些工具需要配置特定的集成(例如 Docker、Overseerr)。工具列表取决于 Homarr 实例中的可用内容。"
}
},
"copy": "复制",
"oneClick": {
"title": "一键安装",
"subtitle": "使用 OAuth 认证——首次使用时在浏览器中进行身份验证",
"subtitle": "使用 OAuth — 首次使用时在浏览器中进行身份验证",
"vsCode": "在 VS Code 中安装",
"cursor": "在 Cursor 中安装",
"copyUrl": "复制 MCP URL",
@@ -5249,16 +5123,16 @@
},
"backup": {
"title": "完整数据库备份",
"metaTitle": "备份 & 恢复",
"metaTitle": "备份与恢复",
"export": {
"title": "导出数据库",
"description": "下载 SQLite 数据库的完整备份,包含所有设置、用户、面板和加密的集成密钥。备份文件以 ZIP 格式打包,内含加密密钥和元数据。",
"button": "备份下载",
"description": "下载包含所有设置、用户、面板和加密集成密钥的 SQLite 数据库的完整备份。备份打包为 ZIP 文件,附带加密密钥和元数据。",
"button": "下载备份",
"error": "导出失败",
"confirm": {
"title": "敏感数据导出",
"warningTitle": "此导出包含您的完整数据库。",
"warningBody": "备份包含所有用户、集成密钥(已加密)、API 密钥、面板配置和应用数据。请妥善保管导出的文件,切勿分享。",
"warningTitle": "此导出包含您的整个数据库。",
"warningBody": "备份包括所有用户、集成密钥(加密)、API 密钥、面板配置和应用数据。将导出的文件安全存储,不要分享。",
"typePrompt": "输入 \"{phrase}\" 以继续",
"submit": "导出数据库",
"cancel": "取消"
@@ -5266,23 +5140,23 @@
},
"import": {
"title": "恢复数据库",
"description": "上传之前导出的备份 ZIP 文件以恢复数据库。此操作将替换整个数据库。如果备份使用的加密密钥不同,密钥将自动重新加密。",
"warning": "恢复备份将完全替换当前数据库并重启应用。此操作不可撤销。",
"description": "上传先前导出的备份 ZIP 以恢复您的数据库。这将替换您的整个数据库。如果备份是使用不同的加密密钥创建的,密钥将自动重新加密。",
"warning": "恢复备份将完全替换您当前的数据库并重启应用程序。此操作无法撤销。",
"dropzone": {
"title": "将备份 ZIP 文件拖放到此处,或点击浏览",
"description": "仅接受来自 Homarr SQLite 备份的 .zip 文件"
"title": "将备份 ZIP 拖到此处或点击浏览",
"description": "仅接受 Homarr SQLite 备份的 .zip 文件"
},
"changeFile": "更改文件",
"button": "恢复数据库",
"success": {
"title": "恢复完成",
"message": "数据库已成功恢复。应用正在重启……"
"message": "数据库已成功恢复。应用程序正在重启……"
},
"error": "导入失败"
},
"preview": {
"title": "备份内容",
"exported": "导出于 {date}",
"exported": "导出时间:{date}",
"loading": "正在分析备份文件……",
"errorTitle": "预览错误",
"entity": "实体",
@@ -5298,12 +5172,12 @@
"search_engine": "搜索引擎"
},
"analyzeError": "分析备份文件失败",
"invalidMissingMetadata": "备份无效:缺少 metadata.json 文件",
"invalidMissingDb": "备份无效:缺少 db.sqlite 文件"
"invalidMissingMetadata": "无效备份:缺少 metadata.json",
"invalidMissingDb": "无效备份:缺少 db.sqlite"
},
"restore": {
"dropzone": {
"title": "将备份 ZIP 文件拖放到此处",
"title": "将备份 ZIP 拖到此处",
"description": "接受 Homarr SQLite 备份的 .zip 文件(最大 {maxSize})"
},
"analyzing": "正在分析备份……",
@@ -5313,7 +5187,7 @@
"analysisError": "分析错误",
"preview": {
"title": "备份内容",
"exported": "导出于 {date}",
"exported": "导出时间:{date}",
"entity": "实体",
"count": "数量",
"boards": "面板",
@@ -5326,43 +5200,43 @@
"media": "媒体",
"search_engine": "搜索引擎"
},
"migrationsApplied": "已应用 {count} 项迁移",
"migrationsAppliedPlural": "已应用 {count} 项迁移",
"migrationsOlderVersion": "此备份来自较旧版本的 Homarr。已应用 {count} 项数据库迁移以将其更新至最新版本。",
"migrationsOlderVersionPlural": "此备份来自较旧版本的 Homarr。已应用 {count} 项数据库迁移以将其更新至最新版本。",
"migrationsUpToDate": "数据库架构已为最新(已应用 {count} 项迁移)",
"upToDate": "已是最新",
"migrationsPending": "{count} 项待处理"
"migrationsApplied": "已应用 {count} 个迁移",
"migrationsAppliedPlural": "已应用 {count} 个迁移",
"migrationsOlderVersion": "此备份来自较旧的 Homarr 版本。已应用 {count} 个架构迁移以使其更新。",
"migrationsOlderVersionPlural": "此备份来自较旧的 Homarr 版本。已应用 {count} 个架构迁移以使其更新。",
"migrationsUpToDate": "数据库架构已是最新(已应用 {count} 个迁移)",
"upToDate": "已更新",
"migrationsPending": "{count} 个待处理"
},
"migration": {
"title": "正在应用迁移({current}/{total})",
"title": "正在应用迁移 ({current}/{total})",
"migrationLabel": "迁移 {index}"
},
"confirm": {
"title": "破坏性操作",
"warningTitle": "这将永久替换您的整个数据库。",
"warningBody": "所有现有面板、用户、应用、集成、设置和会话都将丢失。此操作不可撤销。恢复完成后应用将重启。",
"warningBody": "所有当前面板、用户、应用、集成、设置和会话都将丢失。此操作无法撤销。应用将在恢复后重启。",
"typePrompt": "输入 \"{phrase}\" 以继续",
"cancel": "取消",
"submit": "恢复数据库"
},
"progress": {
"title": "正在恢复数据库……",
"extracting": "正在解压备份压缩包",
"migrating": "正在应用数据库迁移",
"encrypting": "正在重新加密集成密钥",
"swapping": "正在替换数据库文件",
"restarting": "正在重启服务器"
"extracting": "提取备份存档",
"migrating": "应用数据库迁移",
"encrypting": "重新加密集成密钥",
"swapping": "替换数据库文件",
"restarting": "重启服务器"
},
"complete": {
"title": "恢复完成",
"message": "数据库已成功恢复。服务器正在重启——请稍后刷新页面。"
"message": "数据库已成功恢复。服务器正在重启——请几秒钟后刷新页面。"
},
"failed": {
"title": "恢复失败"
},
"invalidMissingMetadata": "备份无效:缺少 metadata.json 文件",
"invalidMissingDb": "备份无效:缺少 db.sqlite 文件",
"invalidMissingMetadata": "无效备份:缺少 metadata.json",
"invalidMissingDb": "无效备份:缺少 db.sqlite",
"analyzeError": "分析备份失败"
}
}
@@ -6044,14 +5918,14 @@
"title": "偏好设置",
"children": {
"detail": {
"title": "选择要更改的偏好设置",
"backHint": "按 Backspace 键或使用返回按钮返回",
"title": "选择要更改的偏好",
"backHint": "按 Backspace 或使用返回按钮返回",
"backAction": "返回"
}
},
"notification": {
"error": {
"message": "无法更新偏好设置"
"message": "无法更新偏好"
}
},
"option": {
@@ -6064,11 +5938,11 @@
},
"board": {
"none": "无",
"unavailable": "当前已选(不可用)"
"unavailable": "当前选择(不可用)"
},
"searchEngine": {
"none": "无",
"unavailable": "当前已选(不可用)"
"unavailable": "当前选择(不可用)"
},
"colorScheme": {
"label": "配色方案",
@@ -6103,42 +5977,42 @@
}
},
"ddgBangs": {
"label": "启用 DuckDuckGo Bang 快捷搜索",
"label": "启用 DuckDuckGo bangs",
"children": {
"detail": {
"title": "启用 DuckDuckGo Bang 快捷搜索"
"title": "启用 DuckDuckGo bangs"
}
}
},
"firstDayOfWeek": {
"label": "每周的第一天",
"label": "一周的第一天",
"children": {
"detail": {
"title": "选择每周的第一天"
"title": "选择一周的第一天"
}
}
},
"homeBoardId": {
"label": "默认面板",
"label": "主面板",
"children": {
"detail": {
"title": "选择您的默认面板"
"title": "选择您的主面板"
}
}
},
"mobileHomeBoardId": {
"label": "移动端默认面板",
"label": "移动端主面板",
"children": {
"detail": {
"title": "选择您的移动端默认面板"
"title": "选择您的移动端主面板"
}
}
},
"pingIconsEnabled": {
"label": "使用图标表示 ping 状态",
"label": "为 ping 使用图标",
"children": {
"detail": {
"title": "使用图标表示 ping 状态"
"title": "为 ping 使用图标"
}
}
},
@@ -6158,16 +6032,16 @@
}
},
"media": {
"help": "搜索要请求的电影或剧集",
"help": "搜索电影或节目以请求",
"requestMovie": "请求电影",
"requestSeries": "请求电视节目",
"openIn": "在 {kind} 中打开",
"group": {
"title": "电影和剧集"
"title": "电影和节目"
},
"action": {
"search": {
"label": "搜索电影或剧集",
"label": "搜索电影或节目",
"description": "开始输入以搜索您的媒体请求集成",
"disabled": {
"noIntegration": "添加 Overseerr、Jellyseerr 或 Seerr 集成以启用媒体搜索。",
@@ -6582,39 +6456,39 @@
"manage": {
"welcome": {
"title": "欢迎使用 Homarr!",
"description": "这是您的控制中心。您可以在这里管理面板、应用、集成、用户和服务器设置。让我们逐一了解每个部分。"
"description": "这是您的控制中心。您可以在此管理面板、应用、集成、用户和服务器设置。让我们浏览每个部分。"
},
"boardsList": {
"title": "您的面板",
"description": "这里是您的所有面板。每个面板都有自己的布局、背景和访问设置。您可以查看每个面板的创建者以及它是公开还是私有的。"
"description": "这里是您的所有面板。每个面板都有自己的布局、背景和访问设置。您可以查看每个面板的创建者以及它是公开的还是私有的。"
},
"boardsCreate": {
"title": "创建面板",
"description": "点击此处创建新面板,或从之前的 Homarr 安装中导入。每个面板都有独立的可分享链接。"
"description": "点击此处创建新面板或从以前的 Homarr 安装中导入。每个面板都有自己可共享的 URL。"
},
"appsList": {
"title": "您的应用",
"description": "这些是您已添加的所有应用——指向您自托管服务的链接。每个应用都配有图标、可选的健康检查,并在面板上显示状态标记。"
"description": "这些是您添加的所有应用——指向自托管服务的链接。每个应用都有图标、可选的健康检查和面板上的状态徽章。"
},
"appsCreate": {
"title": "添加应用",
"description": "在此处注册新服务。设置名称、URL 和图标后,它将以磁贴形式出现在您的面板上,并带有实时状态监控。"
"description": "在此注册新服务。为其命名、URL 和图标——它将作为磁贴显示在您的面板上,并带有实时状态监控。"
},
"integrationsList": {
"title": "您的集成",
"description": "这些是已连接到 Homarr 的服务——Sonarr、Radarr、Plex、Jellyfin 以及 50 多种其他服务。所有凭证均以加密方式存储。"
"description": "这些是连接到 Homarr 的服务——Sonarr、Radarr、Plex、Jellyfin 及 50 多种其他服务。所有凭据在静态时均加密。"
},
"integrationsCreate": {
"title": "添加集成",
"description": "在此处连接新服务。选择类型,输入 URL 和 API 密钥——Homarr 会在保存前验证连接。"
"description": "在此连接新服务。选择类型,输入 URL 和 API 密钥——Homarr 在保存前会验证连接。"
},
"usersList": {
"title": "您的用户",
"description": "查看所有拥有访问权限的用户。将用户归入不同组别,并控制谁可以查看或编辑每个面板。"
"description": "查看所有有访问权限的人员。将用户组织到组中,并控制谁可以查看或编辑每个面板。"
},
"usersCreate": {
"title": "创建用户",
"description": "在此处添加新用户账号。您也可以使用邀请链接简化入门流程。导览已结束——您已准备就绪!"
"description": "在此添加新用户账户。您也可以使用邀请链接方便地引入。导览到此结束——您已准备就绪!"
}
},
"board": {
@@ -6628,15 +6502,15 @@
},
"settings": {
"title": "面板设置",
"description": "更改此面板的名称、列数、背景、颜色以及访问权限。每个面板均可设为公开或私有。"
"description": "更改此面板的名称、列数、背景、颜色以及访问权限。每个面板可以是公开或私有的。"
},
"switcher": {
"title": "切换面板",
"description": "在不同面板之间跳转。您可以为不同用途创建独立的面板——媒体、网络、按用户划分,或任何您喜欢的分类。"
"description": "在面板间跳转。为不同目的创建独立的面板——媒体、网络、按用户或任何您喜欢的。"
},
"userMenu": {
"title": "您的个人资料",
"description": "更改您的配色方案、语言、默认面板和偏好设置。您也可以从这里跳转到管理区域。"
"description": "更改您的配色方案、语言、主面板和偏好。您也可以从此处跳转到管理中心。"
}
}
}
+3 -49
View File
@@ -1204,10 +1204,6 @@
"bazarr": {
"name": "crwdns5799:0crwdne5799:0",
"description": "crwdns5801:0crwdne5801:0"
},
"wud": {
"name": "crwdns6193:0crwdne6193:0",
"description": "crwdns6195:0crwdne6195:0"
}
},
"media": {
@@ -1290,20 +1286,10 @@
"here": "crwdns552:0crwdne552:0",
"iconPicker": {
"label": "crwdns554:0crwdne554:0",
"header": "crwdns6227:0{countIcons}crwdne6227:0",
"headerLoading": "crwdns6229:0crwdne6229:0",
"header": "crwdns556:0{countIcons}crwdne556:0",
"headerLoading": "crwdns5681:0crwdne5681:0",
"noResults": "crwdns5683:0{search}crwdne5683:0",
"error": "crwdns5685:0crwdne5685:0",
"description": "crwdns6225:0crwdne6225:0",
"clear": "crwdns6231:0crwdne6231:0",
"uploadImage": "crwdns6233:0crwdne6233:0",
"results": "crwdns6235:0crwdne6235:0",
"localImages": "crwdns6237:0crwdne6237:0",
"svgIcons": "crwdns6239:0crwdne6239:0",
"otherImages": "crwdns6241:0crwdne6241:0",
"urlLoading": "crwdns6243:0crwdne6243:0",
"urlReady": "crwdns6245:0crwdne6245:0",
"urlError": "crwdns6247:0crwdne6247:0"
"error": "crwdns5685:0crwdne5685:0"
},
"colorScheme": {
"options": {
@@ -4029,38 +4015,6 @@
"subtitle": "crwdns4975:0crwdne4975:0"
}
}
},
"wud": {
"name": "crwdns6197:0crwdne6197:0",
"description": "crwdns6199:0crwdne6199:0",
"title": "crwdns6201:0crwdne6201:0",
"monitored": "crwdns6203:0crwdne6203:0",
"updatesAvailable": "crwdns6205:0count={count}crwdne6205:0",
"updateAvailable": "crwdns6207:0crwdne6207:0",
"openReleaseNotes": "crwdns6209:0crwdne6209:0",
"option": {
"showTitle": {
"label": "crwdns6211:0crwdne6211:0"
},
"showRing": {
"label": "crwdns6213:0crwdne6213:0"
},
"layout": {
"label": "crwdns6215:0crwdne6215:0",
"option": {
"horizontal": {
"label": "crwdns6217:0crwdne6217:0"
},
"vertical": {
"label": "crwdns6219:0crwdne6219:0"
}
}
},
"showUpdateList": {
"label": "crwdns6221:0crwdne6221:0",
"description": "crwdns6223:0crwdne6223:0"
}
}
}
},
"widgetPreview": {
File diff suppressed because it is too large. Load diff
+4 -50
View File
@@ -1150,10 +1150,6 @@
"bazarr": {
"name": "",
"description": ""
},
"wud": {
"name": "Hvad Er Opad Docker",
"description": "Overvåg hvilke af dine Docker-containere der er tilgængelige billedopdateringer"
}
},
"media": {
@@ -1236,20 +1232,10 @@
"here": "her",
"iconPicker": {
"label": "Ikon-URL",
"header": "Søg efter {countIcons} billeder efter navn",
"headerLoading": "Søg billeder efter navn",
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig.",
"headerLoading": "",
"noResults": "",
"error": "",
"description": "Søg i biblioteket, indsæt en billed-URL, eller upload en fil.",
"clear": "Ryd billede",
"uploadImage": "Upload billede",
"results": "Billede søgeresultater",
"localImages": "Dine billeder",
"svgIcons": "SVG ikoner",
"otherImages": "Andre billeder",
"urlLoading": "Kontrollerer billede…",
"urlReady": "Billedet er klar til brug",
"urlError": "Dette billede kunne ikke indlæses. Tjek URL'en og prøv igen."
"error": ""
},
"colorScheme": {
"options": {
@@ -2856,7 +2842,7 @@
"option": {
"albumId": {
"label": "Album ID",
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
},
"rotationIntervalSeconds": {
"label": "Interval i sekunder",
@@ -3960,38 +3946,6 @@
"subtitle": "Netværkstrafik af Docker-containere"
}
}
},
"wud": {
"name": "Hvad Er Opad Docker",
"description": "Vis hvor mange overvågede containere der er tilgængelige opdateringer.",
"title": "Hvad Er Opad Docker",
"monitored": "Overvåget",
"updatesAvailable": "{count, plural, one {# opdatering tilgængelig} other {# opdateringer tilgængelige}}",
"updateAvailable": "Opdatering tilgængelig",
"openReleaseNotes": "Åbn udgivelsesnoter",
"option": {
"showTitle": {
"label": "Vis titel"
},
"showRing": {
"label": "Vis opdateringsring"
},
"layout": {
"label": "Layout",
"option": {
"horizontal": {
"label": "Horisontal"
},
"vertical": {
"label": "Vertikal"
}
}
},
"showUpdateList": {
"label": "Vis liste over container-opdatering",
"description": "Lister hver overvåget beholder, der har en billedopdatering tilgængelig"
}
}
}
},
"widgetPreview": {
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more