Compare commits

..
68 changed files with 982 additions and 2299 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:
+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')
+2 -8
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`
@@ -105,6 +99,6 @@ When documentation is needed, use these locations:
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
+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
Database migrations run automatically when Homarr starts. 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 [v2 beta setup 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.
@@ -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,88 @@
---
title: Try the v2 beta
sidebar_position: 2
tags:
- Getting started
- Upgrade
- Migration
- Backup
---
This page covers the temporary Homarr v2 public beta. Try the [live demo](https://app-v2.preview.homarr.dev/)
without installing anything, or run the beta image published in the
[public beta announcement](https://github.com/homarr-labs/homarr/issues/6600):
```sh
docker pull ghcr.io/homarr-labs/homarr-test:v2
```
For a fresh beta installation, follow the [Docker installation guide](/docs/getting-started/installation/docker),
use `ghcr.io/homarr-labs/homarr-test:v2` as the image, and use a separate `/appdata` volume.
Set `WORKSHOP_API_URL=https://v2.preview.homarr.dev` to use the preview Workshop.
Homarr runs database migrations automatically at startup; no manual database migration command is needed.
Once v2 is released, existing installations can use their normal update process. The beta image below is temporary.
:::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.
:::
## Try the beta with existing v1 data
1. Change the Homarr image to `ghcr.io/homarr-labs/homarr-test:v2`.
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. Keep using the beta image for beta updates. To return to v1, restore the pre-beta backup as described below.
Keep your other existing Compose settings, including environment variables. For Docker Compose, update the image and preview Workshop URL before the usual update:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr-test:v2
environment:
WORKSHOP_API_URL: https://v2.preview.homarr.dev
```
```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.
@@ -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",
};
@@ -24,14 +24,12 @@ The What's Up Docker integration lets Homarr connect to your [WUD](https://githu
<AddingIntegration />
:::info URL configuration
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr uses `/api/containers` under that URL for both connection testing and widget data.
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
:::
### Secrets
WUD 9 requires authentication. Enter your WUD username and password in Homarr; the initial admin credentials configured with `WUD_AUTH_ADMIN_USER` and `WUD_AUTH_ADMIN_PASSWORD` work with Basic authentication. Older WUD versions without authentication can leave both fields empty.
Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside a trusted internal network.
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
<IntegrationSecrets
secrets={[
@@ -42,7 +40,7 @@ Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside
{
credentials: ["username", "password"],
steps: [
"Use your WUD account credentials; older versions can use a configured WUD_AUTH_BASIC_* user",
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
@@ -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.
@@ -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,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",
},
],
},
};
+1 -2
View File
@@ -2304,8 +2304,7 @@
"traefik",
"customApi",
"assistant",
"wud",
"llamacpp"
"wud"
]
},
"options": {
@@ -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(
@@ -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>
);
@@ -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;
}
}
+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 --"
},
+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,
};
}),
});
@@ -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;
-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;
@@ -198,7 +198,7 @@ describe("WudIntegration authentication", () => {
expect(requestInit?.timeout).toBe(10_000);
});
test("sends configured Basic auth credentials over an HTTP URL", async () => {
test("does not send credentials over a non-HTTPS URL", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleContainersResponse), {
status: 200,
@@ -209,7 +209,7 @@ describe("WudIntegration authentication", () => {
const integration = new WudIntegration({
id: "test-wud-http",
name: "Test WUD HTTP",
url: "http://wud:3000",
url: "http://wud.example.com",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: TEST_USERNAME },
@@ -221,7 +221,7 @@ describe("WudIntegration authentication", () => {
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`);
expect(headers.Authorization).toBeUndefined();
});
});
@@ -238,16 +238,15 @@ describe("WudIntegration testing endpoint", () => {
fetchAsync: vi.fn().mockResolvedValue(response),
}) as unknown as IntegrationTestingInput;
test("returns success when /api/containers responds 200", async () => {
const input = makeInput(new Response(JSON.stringify(sampleContainersResponse), { status: 200 }));
test("returns success when /api/app responds 200", async () => {
const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }));
const result = await invokeTesting(createIntegration(), input);
expect(result.success).toBe(true);
expect(String(vi.mocked(input.fetchAsync).mock.calls[0]?.[0])).toBe(`${TEST_URL}/api/containers`);
});
test("returns a status-code failure when /api/containers responds non-OK", async () => {
test("returns a status-code failure when /api/app responds non-OK", async () => {
const input = makeInput(new Response("unauthorized", { status: 401 }));
const result = await invokeTesting(createIntegration(), input);
@@ -256,7 +255,7 @@ describe("WudIntegration testing endpoint", () => {
});
test("passes the Basic auth header through to fetchAsync when configured", async () => {
const response = new Response(JSON.stringify(sampleContainersResponse), { status: 200 });
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
const input = makeInput(response);
await invokeTesting(createIntegrationWithBasicAuth(), input);
@@ -14,7 +14,7 @@ const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000;
export class WudIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/containers"), {
const response = await input.fetchAsync(this.url("/api/app"), {
headers: this.getAuthHeaders(),
});
@@ -23,10 +23,10 @@ export class WudIntegration extends Integration {
}
try {
await parseWudContainersResponseAsync(response);
await response.json();
} catch (error) {
return TestConnectionError.ParseResult(
new ParseError("Invalid WUD containers response", {
new ParseError("Invalid WUD app response", {
cause: error instanceof Error ? error : new Error(String(error)),
}),
);
@@ -55,6 +55,10 @@ export class WudIntegration extends Integration {
return undefined;
}
if (this.url("/api/app").protocol !== "https:") {
return undefined;
}
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
-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();
},
});
+1 -81
View File
@@ -1027,8 +1027,7 @@
"analytics": "分析",
"vpn": "VPN",
"mediaLibrary": "媒体库",
"uptimeMonitoring": "运行时间监控",
"systemMonitoring": "系统监控"
"uptimeMonitoring": "运行时间监控"
},
"testConnection": {
"action": {
@@ -1356,10 +1355,6 @@
"bazarr": {
"name": "Bazarr",
"description": "Sonarr 和 Radarr 的配套应用,用于管理和下载字幕"
},
"llamacpp": {
"name": "llama.cpp",
"description": "监控本地 llama.cpp llama-server:健康状态、模型与生成指标"
}
},
"media": {
@@ -4176,81 +4171,6 @@
"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"
}
}
},
"widgetPreview": {
+1 -84
View File
@@ -1424,8 +1424,7 @@
"vpn": "VPN",
"mediaLibrary": "Media Library",
"uptimeMonitoring": "Uptime Monitoring",
"beszel": "Beszel",
"systemMonitoring": "System Monitoring"
"beszel": "Beszel"
},
"testConnection": {
"action": {
@@ -1717,10 +1716,6 @@
"use": "Select integrations in items",
"interact": "Interact with integrations",
"full": "Full integration access"
},
"llamacpp": {
"name": "llama.cpp",
"description": "Monitor a local llama.cpp llama-server: health, model and generation metrics"
}
},
"media": {
@@ -5210,81 +5205,6 @@
"uptime": "Uptime",
"agent": "Agent"
}
},
"llamacpp": {
"name": "llama.cpp",
"description": "Show the health, loaded model and generation speed of your local llama.cpp server.",
"title": "llama.cpp",
"status": {
"unhealthy": "Unhealthy ({status})"
},
"idle": "Idle",
"speedTooltip": {
"current": "Current generation speed",
"average": "Average generation speed since startup",
"request": "Average speed since this request started"
},
"busy": "{count, plural, one {# request running} other {# requests running}}",
"modelInfo": {
"context": "Context",
"size": "File size",
"parameters": "Parameters",
"model": "Model",
"quantization": "Quantization"
},
"stats": {
"tokensProcessed": "Tokens processed",
"contextUsage": "Context / KV usage",
"cacheHitRate": "Prompt cache hit rate",
"tokensGenerated": "Tokens generated"
},
"error": {
"unreachable": "llama-server is not reachable"
},
"unit": {
"speed": "{value} t/s",
"parameters": "{value}B",
"none": "—",
"speedShort": "t/s"
},
"option": {
"showTitle": {
"label": "Show title"
},
"showModelInfo": {
"label": "Show model info"
},
"showContextUsage": {
"label": "Show context usage"
},
"showCacheHitRate": {
"label": "Show cache hit rate"
},
"showSpeedStats": {
"label": "Show speed stats"
},
"showRequests": {
"label": "Show request load"
},
"showTokenStats": {
"label": "Show token totals"
},
"showSpeculative": {
"label": "Show speculative decoding"
}
},
"advanced": {
"promptSpeed": "Prompt speed",
"avgSpeed": "Avg generation",
"requests": "Requests",
"deferred": "Deferred",
"slots": "{processing}/{total} slots busy",
"speculative": "Speculative decoding",
"avgPrompt": "Avg prompt",
"specDrafts": "Spec. drafts",
"specAccepted": "Spec. accepted tokens",
"specDraftTokens": "Spec. draft tokens"
}
}
},
"widgetPreview": {
@@ -6237,9 +6157,6 @@
"lm-studio": {
"description": "An LM Studio local server with OpenAI compatibility enabled."
},
"llama-cpp": {
"description": "A local or network llama.cpp server using its OpenAI-compatible API."
},
"custom": {
"label": "Custom endpoint",
"description": "Any other endpoint compatible with OpenAI Chat Completions."
+1 -81
View File
@@ -822,8 +822,7 @@
"analytics": "分析",
"vpn": "VPN",
"mediaLibrary": "媒體庫",
"uptimeMonitoring": "運作時間監控",
"systemMonitoring": "系統監控"
"uptimeMonitoring": "運作時間監控"
},
"testConnection": {
"action": {
@@ -1151,10 +1150,6 @@
"bazarr": {
"name": "Bazarr",
"description": "Sonarr 和 Radarr 的配套應用程式,管理和下載字幕"
},
"llamacpp": {
"name": "llama.cpp",
"description": "監控本地 llama.cpp llama-server:健康狀態、模型與生成指標"
}
},
"media": {
@@ -3951,81 +3946,6 @@
"error": {
"internalServerError": "無法擷取 Traefik 資料"
}
},
"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"
}
}
},
"widgetPreview": {
-2
View File
@@ -20,7 +20,6 @@ import {
IconClock,
IconClockPlay,
IconCloud,
IconCpu,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
@@ -117,5 +116,4 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
traefik: IconRoute,
customApi: IconApi,
wud: IconBrandDocker,
llamacpp: IconCpu,
};
@@ -1,571 +0,0 @@
"use client";
import { useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { Box, Center, Group, Progress, RingProgress, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import {
IconBrain,
IconBolt,
IconCircleX,
IconDatabase,
IconFileText,
IconGauge,
IconLayersIntersect,
IconServer,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { formatBytes, formatNumber } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
// Board grid: a widget N cells wide is N*200 + (N-1)*12 px (1→200, 2→412, 3→624, 4→836).
// Width drives how much info fits side-by-side; height only matters for the
// single-row (short) case where the widget must drop down to essentials.
const isNarrowWidth = (width: number) => width < 300; // 1 cell (200px)
const isWideWidth = (width: number) => width >= 550; // 3+ cells (624px+)
const isShortHeight = (height: number) => height < 300; // 1 row (200px)
const ringSizeByScale = [
{ minWidth: 320, size: 108 },
{ minWidth: 220, size: 92 },
{ minWidth: 0, size: 68 },
] as const;
const getContextColor = (percent: number) => (percent > 90 ? "red" : percent > 75 ? "yellow" : "blue");
export default function LlamacppWidget({
integrationIds,
options,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"llamacpp">) {
const integrationId = integrationIds[0];
if (!integrationId) {
throw new NoIntegrationSelectedError();
}
return (
<LlamacppContent
integrationId={integrationId}
options={options}
width={width}
height={height}
displayMode={displayMode}
/>
);
}
interface LlamacppContentProps {
integrationId: string;
options: WidgetComponentProps<"llamacpp">["options"];
width: number;
height: number;
displayMode: "compact" | "advanced";
}
function LlamacppContent({ integrationId, options, width, height, displayMode }: LlamacppContentProps) {
const t = useI18n("widget.llamacpp");
const { data, isError } = clientApi.widget.llamacpp.getStats.useQuery({ integrationId });
// Tracks the in-flight request across polls so the widget can show the average
// speed since that request started: /slots reports the request id (taskId) and how
// many tokens it has generated so far (requestDecodedTokens). The server resets that
// counter to 0 when a new request starts, so a fresh taskId means a fresh request.
const requestRef = useRef<{ taskId: number; baseTokens: number; baseTimeMs: number } | null>(null);
const perRequestSpeedTps = useMemo(() => {
const taskId = data?.stats.metrics.taskId ?? null;
const decoded = data?.stats.metrics.requestDecodedTokens ?? null;
if (taskId === null || decoded === null) {
requestRef.current = null;
return null;
}
const nowMs = Date.now();
const current = requestRef.current;
if (current === null || current.taskId !== taskId) {
requestRef.current = { taskId, baseTokens: decoded, baseTimeMs: nowMs };
return null;
}
const elapsedSeconds = (nowMs - current.baseTimeMs) / 1000;
const deltaTokens = decoded - current.baseTokens;
if (elapsedSeconds <= 0 || deltaTokens < 0) {
return null;
}
const speed = deltaTokens / elapsedSeconds;
return Number.isFinite(speed) && speed > 0 ? speed : null;
}, [data]);
if (isError) {
return (
<Center h="100%" w="100%" p="sm">
<Group gap="xs" wrap="nowrap" miw={0}>
<IconCircleX size={20} color="red" />
<Text size="sm" c="dimmed" lineClamp={1}>
{t("error.unreachable")}
</Text>
</Group>
</Center>
);
}
if (!data) {
return <WidgetEmptyState />;
}
const stats = data.stats;
const isHealthy = stats.health === "ok";
const model = stats.model;
const contextUsage = stats.contextUsage;
const slots = stats.slots;
const requestsProcessing = stats.metrics.requestsProcessing;
const isBusy = requestsProcessing !== null && requestsProcessing > 0;
let displayedSpeed: number | null;
let speedMode: "request" | "live" | "average";
if (isBusy && perRequestSpeedTps !== null) {
displayedSpeed = perRequestSpeedTps;
speedMode = "request";
} else if (isBusy && (stats.metrics.generationSpeedTps ?? 0) > 0) {
displayedSpeed = stats.metrics.generationSpeedTps;
speedMode = "live";
} else {
displayedSpeed = stats.metrics.avgGenerationSpeedTps;
speedMode = "average";
}
const speedTooltip =
speedMode === "request"
? t("speedTooltip.request")
: speedMode === "live"
? t("speedTooltip.current")
: t("speedTooltip.average");
const speedValue = displayedSpeed !== null && displayedSpeed > 0 ? formatNumber(displayedSpeed, 1) : t("unit.none");
const statusColor = isHealthy ? "green" : "red";
const statusLabel = isHealthy ? undefined : t("status.unhealthy", { status: stats.health });
const contextPercent = options.showContextUsage && contextUsage ? contextUsage.percent : null;
const contextLabel = contextUsage
? `${formatNumber(contextUsage.usedTokens, 0)} / ${formatNumber(contextUsage.contextSize, 0)} (${contextUsage.percent}%)`
: null;
const ringSize = ringSizeByScale.find(({ minWidth }) => Math.min(width, height) >= minWidth)?.size ?? 68;
const ring = (
<Tooltip label={speedTooltip} withArrow position="bottom">
<RingProgress
size={ringSize}
thickness={Math.max(6, Math.round(ringSize / 10))}
roundCaps
sections={[
{ value: contextPercent ?? 0, color: contextPercent !== null ? getContextColor(contextPercent) : "gray" },
]}
label={
<Center>
<Stack gap={0} align="center" style={{ lineHeight: 1.15 }}>
<Text size={ringSize >= 92 ? "lg" : "sm"} fw={800} lineClamp={1}>
{speedValue}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{t("unit.speedShort")}
</Text>
</Stack>
</Center>
}
/>
</Tooltip>
);
const modelLine = model && (
<Group gap={6} wrap="nowrap" miw={0}>
<IconBrain size={14} color="dimmed" style={{ flexShrink: 0 }} />
<Tooltip label={model.id} withArrow>
<Text size="xs" lineClamp={1} style={{ maxWidth: "100%" }}>
{model.name}
</Text>
</Tooltip>
{model.quantization && (
<Text size="xs" c="dimmed" fw={600} lineClamp={1} style={{ flexShrink: 0 }}>
{model.quantization}
</Text>
)}
</Group>
);
const busyLabel =
requestsProcessing !== null ? (isBusy ? t("busy", { count: requestsProcessing }) : t("idle")) : null;
const promptSpeed = stats.metrics.promptSpeedTps;
const avgPromptSpeed = stats.metrics.avgPromptSpeedTps;
const cacheHitRate = stats.metrics.promptCacheHitRate;
const tokensProcessed = stats.metrics.tokensProcessed;
const tokensGenerated = stats.metrics.tokensGenerated;
const requestsDeferred = stats.metrics.requestsDeferred;
const specDraftTokens = stats.metrics.specDraftTokens;
const specAcceptedTokens = stats.metrics.specAcceptedTokens;
const specDrafts = stats.metrics.specDrafts;
const hasSpecStats = specDraftTokens !== null || specAcceptedTokens !== null || specDrafts !== null;
if (displayMode === "advanced") {
return (
<Box h="100%" w="100%">
<ScrollArea h="100%">
<Stack gap="md" p="md">
<Group gap="xs" wrap="nowrap" miw={0}>
<StatusDot color={statusColor} label={statusLabel} />
<Text size="md" fw={700} lineClamp={1}>
{t("title")}
</Text>
{options.showRequests && busyLabel && (
<Text size="xs" c="dimmed" lineClamp={1}>
{busyLabel}
</Text>
)}
</Group>
<Group gap="lg" wrap="nowrap" miw={0}>
{ring}
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
{options.showSpeedStats && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("advanced.promptSpeed")}
value={
promptSpeed !== null && promptSpeed > 0
? t("unit.speed", { value: formatNumber(promptSpeed, 1) })
: t("unit.none")
}
/>
)}
{options.showSpeedStats && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("advanced.avgPrompt")}
value={
avgPromptSpeed !== null && avgPromptSpeed > 0
? t("unit.speed", { value: formatNumber(avgPromptSpeed, 1) })
: t("unit.none")
}
/>
)}
{options.showSpeedStats && stats.metrics.avgGenerationSpeedTps !== null && (
<AdvancedRow
icon={<IconGauge size={14} color="dimmed" />}
label={t("advanced.avgSpeed")}
value={
(stats.metrics.avgGenerationSpeedTps ?? 0) > 0
? t("unit.speed", { value: formatNumber(stats.metrics.avgGenerationSpeedTps, 1) })
: t("unit.none")
}
/>
)}
{options.showContextUsage && contextUsage && (
<AdvancedRow
icon={<IconDatabase size={14} color="dimmed" />}
label={t("stats.contextUsage")}
value={contextLabel ?? "—"}
/>
)}
{options.showRequests && requestsProcessing !== null && (
<AdvancedRow
icon={<IconServer size={14} color="dimmed" />}
label={t("advanced.requests")}
value={formatNumber(requestsProcessing, 0)}
/>
)}
{options.showRequests && requestsDeferred !== null && (
<AdvancedRow
icon={<IconLayersIntersect size={14} color="dimmed" />}
label={t("advanced.deferred")}
value={formatNumber(requestsDeferred, 0)}
/>
)}
</Stack>
</Group>
{(options.showRequests || (options.showSpeculative && slots?.speculative)) && slots && (
<Group gap="sm" wrap="nowrap" miw={0}>
{options.showRequests && (
<Text size="xs" c="dimmed" lineClamp={1}>
{t("advanced.slots", { processing: slots.processing, total: slots.total })}
</Text>
)}
{options.showSpeculative && slots.speculative && (
<Text size="xs" c="dimmed" lineClamp={1} fw={600}>
{t("advanced.speculative")}
</Text>
)}
</Group>
)}
{options.showModelInfo && model && (
<Stack gap="xs">
<AdvancedRow
icon={<IconBrain size={14} color="dimmed" />}
label={t("modelInfo.model")}
value={model.name}
valueTooltip={model.id}
/>
{model.quantization && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("modelInfo.quantization")}
value={model.quantization}
/>
)}
{model.contextSize !== null && (
<AdvancedRow
icon={<IconDatabase size={14} color="dimmed" />}
label={t("modelInfo.context")}
value={formatNumber(model.contextSize, 0)}
/>
)}
{model.fileSizeBytes !== null && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("modelInfo.size")}
value={formatBytes(model.fileSizeBytes)}
/>
)}
{model.parameterCount !== null && (
<AdvancedRow
icon={<IconBrain size={14} color="dimmed" />}
label={t("modelInfo.parameters")}
value={t("unit.parameters", { value: formatNumber(model.parameterCount / 1e9, 2) })}
/>
)}
</Stack>
)}
{options.showTokenStats && (tokensProcessed !== null || tokensGenerated !== null) && (
<Group gap="md" wrap="nowrap" miw={0}>
{tokensProcessed !== null && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("stats.tokensProcessed")}
value={formatNumber(tokensProcessed, 0)}
/>
)}
{tokensGenerated !== null && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("stats.tokensGenerated")}
value={formatNumber(tokensGenerated, 0)}
/>
)}
</Group>
)}
{options.showSpeculative && hasSpecStats && (
<Stack gap="xs">
{specDrafts !== null && (
<AdvancedRow
icon={<IconLayersIntersect size={14} color="dimmed" />}
label={t("advanced.specDrafts")}
value={formatNumber(specDrafts, 0)}
/>
)}
{specDraftTokens !== null && (
<AdvancedRow
icon={<IconFileText size={14} color="dimmed" />}
label={t("advanced.specDraftTokens")}
value={formatNumber(specDraftTokens, 0)}
/>
)}
{specAcceptedTokens !== null && (
<AdvancedRow
icon={<IconBolt size={14} color="dimmed" />}
label={t("advanced.specAccepted")}
value={formatNumber(specAcceptedTokens, 0)}
/>
)}
</Stack>
)}
{options.showCacheHitRate && cacheHitRate !== null && (
<AdvancedRow
icon={<IconDatabase size={14} color="dimmed" />}
label={t("stats.cacheHitRate")}
value={`${cacheHitRate}%`}
progress={{ value: cacheHitRate, color: cacheHitRate > 75 ? "green" : "blue" }}
/>
)}
</Stack>
</ScrollArea>
</Box>
);
}
// Compact (default) mode — density adapts to the widget's grid footprint.
if (isNarrowWidth(width)) {
return (
<Center h="100%" w="100%">
<Stack gap={isShortHeight(height) ? 0 : 4} align="center" p="xs" style={{ position: "relative" }}>
<Center pos="relative">
{ring}
<Box pos="absolute" top={0} right={0} style={{ zIndex: 1 }}>
<StatusDot color={statusColor} label={statusLabel} />
</Box>
</Center>
{!isShortHeight(height) && options.showModelInfo && modelLine}
</Stack>
</Center>
);
}
const rightColumn = (
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
{options.showModelInfo && modelLine}
{options.showRequests && busyLabel && (
<Text size="xs" c="dimmed" lineClamp={1}>
{busyLabel}
</Text>
)}
{options.showContextUsage && contextUsage && (
<Group gap={6} wrap="nowrap" miw={0} w="100%" align="center">
<Progress
value={contextUsage.percent}
size="xs"
radius="xs"
color={getContextColor(contextUsage.percent)}
style={{ flex: 1, minWidth: 0 }}
/>
<Text size="xs" c="dimmed" lineClamp={1} style={{ flexShrink: 0 }}>
{contextUsage.percent}%
</Text>
</Group>
)}
{isWideWidth(width) && (
<Stack gap="xs">
{options.showSpeedStats && promptSpeed !== null && promptSpeed > 0 && (
<InfoRow
label={t("advanced.promptSpeed")}
value={t("unit.speed", { value: formatNumber(promptSpeed, 1) })}
/>
)}
{options.showCacheHitRate && cacheHitRate !== null && (
<InfoRow label={t("stats.cacheHitRate")} value={`${cacheHitRate}%`} />
)}
{options.showTokenStats && (tokensProcessed !== null || tokensGenerated !== null) && (
<InfoRow
label={t("stats.tokensProcessed")}
value={
tokensProcessed !== null && tokensGenerated !== null
? formatNumber(tokensProcessed + tokensGenerated, 0)
: tokensProcessed !== null
? formatNumber(tokensProcessed, 0)
: "—"
}
/>
)}
</Stack>
)}
</Stack>
);
if (isShortHeight(height)) {
return (
<Center h="100%" w="100%">
<Group gap="md" w="100%" wrap="nowrap" miw={0} justify="center" p="xs">
{ring}
{!isNarrowWidth(width) && (
<Stack gap={2} style={{ minWidth: 0 }}>
{options.showModelInfo && modelLine}
{options.showRequests && busyLabel && (
<Text size="xs" c="dimmed" lineClamp={1}>
{busyLabel}
</Text>
)}
</Stack>
)}
</Group>
</Center>
);
}
return (
<Box h="100%" p="xs" pos="relative">
<Group pos="absolute" top={6} right={10} gap={0} style={{ zIndex: 2 }}>
<StatusDot color={statusColor} label={statusLabel} />
</Group>
<Center h="100%">
<Group gap="md" w="100%" wrap="nowrap" miw={0}>
{ring}
{rightColumn}
</Group>
</Center>
</Box>
);
}
const StatusDot = ({ color, label }: { color: string; label?: string }) => {
const dot = (
<Box
aria-label={label}
style={{
width: 9,
height: 9,
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${color}-6)`,
flexShrink: 0,
cursor: "default",
}}
/>
);
if (!label) return dot;
return (
<Tooltip label={label} withArrow position="left">
{dot}
</Tooltip>
);
};
interface AdvancedRowProps {
icon: ReactNode;
label: string;
value: string;
valueTooltip?: string;
progress?: { value: number; color: string };
}
const AdvancedRow = ({ icon, label, value, valueTooltip, progress }: AdvancedRowProps) => (
<Stack gap={3} style={{ minWidth: 150 }}>
<Group gap={6} wrap="nowrap" miw={0} justify="space-between" w="100%">
<Group gap={6} wrap="nowrap" miw={0}>
{icon}
<Text size="xs" c="dimmed" lineClamp={1}>
{label}
</Text>
</Group>
{valueTooltip ? (
<Tooltip label={valueTooltip} withArrow>
<Text size="xs" fw={600} lineClamp={1} style={{ maxWidth: 240 }}>
{value}
</Text>
</Tooltip>
) : (
<Text size="xs" fw={600} lineClamp={1} style={{ maxWidth: 240 }}>
{value}
</Text>
)}
</Group>
{progress && <Progress value={progress.value} size="xs" radius="xs" color={progress.color} />}
</Stack>
);
const InfoRow = ({ label, value }: { label: string; value: string }) => (
<Group justify="space-between" wrap="nowrap" miw={0}>
<Text size="xs" c="dimmed" lineClamp={1}>
{label}
</Text>
<Text size="xs" fw={600} lineClamp={1}>
{value}
</Text>
</Group>
);
-24
View File
@@ -1,24 +0,0 @@
import { IconCpu } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("llamacpp", {
icon: IconCpu,
refetchInterval: 5,
supportsAdvancedFocus: true,
...getWidgetIntegrationConfig("llamacpp"),
createOptions() {
return optionsBuilder.from((factory) => ({
showModelInfo: factory.switch({ defaultValue: true }),
showContextUsage: factory.switch({ defaultValue: true }),
showCacheHitRate: factory.switch({ defaultValue: true }),
showSpeedStats: factory.switch({ defaultValue: true }),
showRequests: factory.switch({ defaultValue: true }),
showTokenStats: factory.switch({ defaultValue: true }),
showSpeculative: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -14,7 +14,6 @@ export const widgetQueryRefetchIntervals = [
},
{ queryKey: [["widget", "firewall"]], intervalSeconds: 10 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
{ queryKey: [["widget", "llamacpp"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 10 },
{
-4
View File
@@ -59,7 +59,6 @@ import type * as video from "./video";
import type * as vpn from "./vpn";
import type * as weather from "./weather";
import type * as wud from "./wud";
import type * as llamacpp from "./llama-cpp";
// Keep these imports explicit so Next.js and Turbopack can discover every widget
// module without loading any widget definition or component eagerly.
@@ -123,7 +122,6 @@ export const widgetModuleLoaders = {
customApi: () => import("./custom-api"),
weather: () => import("./weather"),
wud: () => import("./wud"),
llamacpp: () => import("./llama-cpp"),
} satisfies { [TKind in WidgetKind]: () => Promise<WidgetImports[TKind]> };
export type WidgetImports = {
@@ -186,7 +184,6 @@ export type WidgetImports = {
customApi: typeof customApi;
weather: typeof weather;
wud: typeof wud;
llamacpp: typeof llamacpp;
};
type WidgetComponentLoaders = {
@@ -256,7 +253,6 @@ export const widgetComponentLoaders = {
customApi: () => import("./custom-api/component"),
weather: () => import("./weather/component"),
wud: () => import("./wud/component"),
llamacpp: () => import("./llama-cpp/component"),
} satisfies WidgetComponentLoaders;
export type WidgetImportKey = keyof WidgetImports;
-34
View File
@@ -2,20 +2,6 @@ import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { configDefaults, defineConfig } from "vitest/config";
// Docker-backed suites are opt-in through pnpm test:integration.
const integrationTests = [
"**/*.integration.spec.ts",
"packages/db/test/mysql-migration.spec.ts",
"packages/db/test/postgresql-migration.spec.ts",
"packages/integrations/test/aria2.spec.ts",
"packages/integrations/test/home-assistant.spec.ts",
"packages/integrations/test/nextcloud.spec.ts",
"packages/integrations/test/nzbget.spec.ts",
"packages/integrations/test/pi-hole.spec.ts",
"packages/integrations/test/sabnzbd.spec.ts",
"packages/integrations/test/technitium.spec.ts",
];
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
@@ -47,7 +33,6 @@ export default defineConfig({
environment: "node",
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
include: ["packages/db/test/**/*.spec.ts"],
exclude: [...configDefaults.exclude, ...integrationTests],
},
},
{
@@ -92,34 +77,15 @@ export default defineConfig({
"packages/docker/**",
"packages/request-handler/**",
"e2e/**",
...integrationTests,
],
},
},
{
extends: true,
test: {
name: "integration",
environment: "node",
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
include: integrationTests,
},
},
{
extends: true,
test: {
name: "e2e",
environment: "node",
include: ["e2e/**/*.spec.ts"],
exclude: [...configDefaults.exclude, "e2e/assistant-docs-screenshots.spec.ts"],
},
},
{
extends: true,
test: {
name: "docs-screenshots",
environment: "node",
include: ["e2e/assistant-docs-screenshots.spec.ts"],
},
},
],