Compare commits

..
Author SHA1 Message Date
ajnart ec8d97a441 docs: remove CI audit report 2026-09-10 19:13:54 +02:00
ajnart 2501fa59dd ci: remove automatic test gates and trim test suites 2026-09-10 19:08:37 +02:00
62 changed files with 318 additions and 2723 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:
+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
+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,8 +80,8 @@ 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.
- `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.
## CI and focused validation
@@ -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.
+44 -36
View File
@@ -8,17 +8,18 @@ tags:
# Model Context Protocol (MCP)
Connect an AI client to Homarr to manage boards, apps, integrations, and other services through MCP tools.
Homarr uses MCP v2 (protocol `2026-07-28`) at `/api/mcp`, with compatibility for 2025 Streamable HTTP clients.
Requests are stateless: each request authenticates independently, without an MCP session ID.
Homarr exposes a stateless [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`. It maps the
current Homarr API to tools for compatible AI clients.
![The AI / MCP management tab](./img/mcp.png)
## Connect a client
The endpoint implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients through a compatibility path. The old
`/api/mcp/mcp` route remains as an alias; use `/api/mcp` for new clients.
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
replacing the URL and API key:
## Configure a client
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
MCP configuration:
```json
{
@@ -33,10 +34,7 @@ replacing the URL and API key:
}
```
Use the complete key, including the ID, dot, and token. Keep it private and use HTTPS for remote connections.
The **AI / MCP** tab on Homarr's API page provides your endpoint, configuration, and available tools.
For clients that only support STDIO, use `mcp-remote`:
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
```json
{
@@ -49,30 +47,29 @@ For clients that only support STDIO, use `mcp-remote`:
}
```
### OAuth
The **AI / MCP** tab on Homarr's API page shows the current endpoint, configuration, and generated tool catalog. Filter
the catalog by namespace, tool name, or description; matching categories expand automatically.
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
Homarr publishes discovery metadata under `/.well-known/`.
## Authentication and permissions
If your reverse proxy does not preserve the public host and protocol, set `BASE_URL` to your public origin,
such as `https://homarr.example.com`, without a path.
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
for clients that support dynamic authorization.
## Permissions and tools
Copy the complete generated API key, including both parts and the dot: `<id>.<token>`. Sending only the ID or token
returns `invalid_token`. Keep the full key private when sharing client configuration or diagnostics.
Tools use the permissions of the API key owner or OAuth user. Queries read data; mutations change it.
Inspect the **AI / MCP** tab or your client's tool list for available actions.
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
server and protected resource through Homarr's `/.well-known/` routes.
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
Use HTTPS when the endpoint crosses an untrusted network.
## Verify the connection
## Verify the endpoint
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
The 2025 compatibility path accepts a compact `tools/list` request:
```sh
curl -X POST \
@@ -83,14 +80,25 @@ curl -X POST \
https://homarr.example.com/api/mcp
```
A successful discovery or initialization response alone does not verify that tools can be listed.
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
## Troubleshooting
Successful `server/discover` or `initialize` responses confirm connectivity, but do not validate the tool catalog.
Use `tools/list` above to check that the client can discover tools. If it returns
`Date cannot be represented in JSON Schema`, the server failed to generate a tool schema; changing the API key will
not resolve that error. Update Homarr to a release containing the invitation date schema fix.
- **`invalid_token`**: copy the complete API key in `<id>.<token>` format.
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
of an unsupported date schema.
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
an alias.
## Tools and resources
The generated catalog includes available board, app, integration, Docker, Kubernetes, media, DNS, download, and other
Homarr procedures. Queries are read-only; mutations change state. The exact catalog depends on the release and current
user permissions, so inspect `tools/list` or the table in Homarr instead of relying on a static list.
The `invite_createInvite` tool accepts `expirationDate` as an ISO 8601 timestamp with a timezone, such as
`2026-12-01T18:00:00Z` or `2026-12-01T19:00:00+01:00`.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
@@ -18,10 +18,6 @@ password. Decimal units are the default. Change them from the user form or **Com
Preferences → Data size units**. Byte rates follow the same preference; bit rates stay decimal and use labels such as
Mb/s. Deleting a user does not delete boards they created.
In demo mode (`DEMO_MODE=true`), user deletion, password changes, and local username changes are disabled for everyone,
including administrators, even when `DEMO_READ_ONLY=false` allows other changes. With demo editing enabled, other
profile preferences remain editable. This applies to the UI and API.
![The Users management page](../img/users.png)
## Groups
@@ -1,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": {
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
import { cookies } from "next/headers";
import { z } from "zod/v4";
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import {
createAssistantGenerationAccessToken,
getAssistantRequestContextEntitiesAsync,
@@ -547,7 +547,19 @@ export async function POST(request: Request) {
}
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
try {
const result = await callMcpTool(caller, mcpTool, executionInput);
const procedure = mcpTool.pathInRouter.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
if (typeof procedure !== "function") {
throw new Error("Procedure not callable");
}
const result = await (procedure as (value: unknown) => Promise<unknown>)(
executionInput && Object.keys(executionInput as object).length > 0 ? executionInput : undefined,
);
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
return toAssistantToolOutput(result, {
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
@@ -2,11 +2,8 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type * as CatalogModule from "../_extract-tools";
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
extractCatalog: vi.fn(),
ipAddress: vi.fn(() => "127.0.0.1"),
toolProcedure: vi.fn(),
loggerWarn: vi.fn(),
@@ -17,9 +14,7 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", async () => ({
callMcpTool: (await import("../../../../../../../packages/api/src/mcp-tools")).callMcpTool,
extractMcpToolsFromProcedures: mocks.extractCatalog,
vi.mock("@homarr/api/mcp", () => ({
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
}));
@@ -59,7 +54,6 @@ vi.mock("../_extract-tools", () => ({
description: "List boards",
type: "query",
pathInRouter: ["board", "getAllBoards"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -67,7 +61,6 @@ vi.mock("../_extract-tools", () => ({
description: "List Custom Widgets",
type: "query",
pathInRouter: ["customWidget", "list"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -75,7 +68,6 @@ vi.mock("../_extract-tools", () => ({
description: "Search Workshop",
type: "query",
pathInRouter: ["customWidget", "workshopSearch"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
],
@@ -313,21 +305,3 @@ describe("MCP protocol errors", () => {
expect(metadata).not.toHaveProperty("message");
});
});
test("logs catalog omissions only when the shared catalog is first built", async () => {
const diagnostic = { name: "broken", path: "broken", reason: "invalid_schema" };
const tools = [{ name: "valid" }];
mocks.extractCatalog.mockReturnValue({ tools, diagnostics: [diagnostic] });
const { extractMcpTools } = await vi.importActual<typeof CatalogModule>("../_extract-tools");
expect(extractMcpTools()).toBe(tools);
expect(extractMcpTools()).toBe(tools);
expect(mocks.extractCatalog).toHaveBeenCalledTimes(1);
expect(mocks.loggerWarn).toHaveBeenCalledExactlyOnceWith("MCP tool omitted from catalog", diagnostic);
});
test("rejects unauthenticated tool calls before execution", async () => {
mocks.authenticate.mockResolvedValue(null);
const { response } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
expect(response.status).toBe(401);
expect(mocks.toolProcedure).not.toHaveBeenCalled();
});
@@ -1,12 +1,11 @@
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
import { createLogger } from "@homarr/core/infrastructure/logs";
const logger = createLogger({ module: "mcp-catalog" });
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
export function extractMcpTools() {
if (cache) return cache.tools;
if (cache) return cache;
cache = extractMcpToolsFromProcedures(mcpRouter);
for (const diagnostic of cache.diagnostics) logger.warn("MCP tool omitted from catalog", diagnostic);
return cache.tools;
return cache;
}
+1 -201
View File
@@ -1,18 +1,12 @@
// @vitest-environment node
import { initTRPC, TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { appManageSchema } from "@homarr/validation/app";
import { extractMcpToolsFromProcedures } from "@homarr/api/mcp";
import type { McpMeta } from "../../../../../../packages/api/src/mcp-tools";
import { createMcpProtocolHandler } from "./_protocol";
vi.mock("@homarr/api/mcp", async () => import("../../../../../../packages/api/src/mcp-tools"));
const modernMetadata = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
@@ -58,7 +52,7 @@ const echoTool: McpTool = {
description: "Echo a message",
type: "query",
pathInRouter: ["echo"],
inputMode: "object",
inputValidator: echoValidator,
inputSchema: z.toJSONSchema(echoValidator),
};
@@ -169,197 +163,3 @@ describe("MCP v2 protocol", () => {
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
});
});
describe.each([false, true])("MCP argument dispatch (modern=%s)", (modern) => {
const trpc = initTRPC.context<{ user: string }>().meta<McpMeta>().create();
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
const call = async (
handler: ReturnType<typeof createMcpProtocolHandler>,
name: string,
argumentsValue: Record<string, unknown>,
) => {
let request = createRequest("tools/call", { name, arguments: argumentsValue }, name);
if (!modern) {
request = new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name, arguments: argumentsValue },
}),
});
}
const response = await handler.fetch(request);
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
return (await parseResponse(response)).result;
};
test("transforms once and does not coerce, insert defaults, or remove properties before tRPC", async () => {
const transform = vi.fn((value: string) => `${value}!`);
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
update: exposed
.input(
z.looseObject({
name: z.string().transform(transform),
limit: z.number().default(7),
}),
)
.mutation(({ input }) => execution(input)),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const realCaller = router.createCaller({ user: "one" });
const dispatch = vi.fn(realCaller.update);
const handler = createMcpProtocolHandler({
caller: { update: dispatch },
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = { name: "hello", extra: "preserved" };
const result = await call(handler, "update", argumentsValue);
expect(result.isError).not.toBe(true);
expect(dispatch).toHaveBeenCalledWith(argumentsValue);
expect(transform).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledWith({ name: "hello!", limit: 7, extra: "preserved" });
await call(handler, "update", { name: "hello", limit: "7" });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledTimes(1);
});
test("retains overlapping chained constraints, refinements, and empty object defaults", async () => {
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
bounded: exposed
.input(z.object({ count: z.number().min(2) }))
.input(z.object({ count: z.number().max(5) }))
.mutation(({ input }) => execution(input)),
refined: exposed
.input(z.object({ value: z.string().refine((value) => value !== "blocked") }))
.mutation(({ input }) => execution(input)),
defaults: exposed.input(z.object({ count: z.number().default(3) })).query(({ input }) => input),
none: exposed.query(({ input }) => ({ absent: input === undefined })),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "one" }),
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const count of [1, 6]) expect((await call(handler, "bounded", { count })).isError).toBe(true);
expect((await call(handler, "refined", { value: "blocked" })).isError).toBe(true);
expect(execution).not.toHaveBeenCalled();
expect((await call(handler, "bounded", { count: 3 })).isError).not.toBe(true);
expect(execution).toHaveBeenCalledWith({ count: 3 });
expect((await call(handler, "defaults", {})).content).toEqual([{ type: "text", text: '{"count":3}' }]);
expect((await call(handler, "none", {})).content).toEqual([{ type: "text", text: '{"absent":true}' }]);
});
test("accepts app inputs that tRPC normalizes before checking constraints", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({ create: exposed.input(appManageSchema).mutation(({ input }) => mutation(input)) });
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = {
name: `${" ".repeat(64)}App `,
description: " description ",
iconUrl: " icon ",
href: null,
pingUrl: " https://example.com ",
};
expect((await call(handler, "create", argumentsValue)).isError).not.toBe(true);
expect(mutation).toHaveBeenCalledExactlyOnceWith({
name: "App",
description: "description",
iconUrl: "icon",
href: null,
pingUrl: "https://example.com",
});
expect((await call(handler, "create", { ...argumentsValue, pingUrl: " javascript:alert(1) " })).isError).toBe(true);
expect(mutation).toHaveBeenCalledTimes(1);
});
test("accepts Zod ISO timestamps without seconds while rejecting invalid dates", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({
invite: exposed
.input(z.object({ expirationDate: z.iso.datetime({ offset: true }) }))
.mutation(({ input }) => mutation(input)),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const expirationDate of ["2026-12-01T18:00Z", "2026-12-01T19:00+01:00"]) {
expect((await call(handler, "invite", { expirationDate })).isError).not.toBe(true);
expect(mutation).toHaveBeenLastCalledWith({ expirationDate });
}
for (const expirationDate of ["2026-02-30T18:00Z", "not-a-date"]) {
expect((await call(handler, "invite", { expirationDate })).isError).toBe(true);
}
expect(mutation).toHaveBeenCalledTimes(2);
});
test("enforces tRPC permissions before executing mutations", async () => {
const mutation = vi.fn(() => "changed");
const router = trpc.router({
restricted: exposed
.use(({ ctx, next }) => {
if (ctx.user !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
return next();
})
.input(z.object({}))
.mutation(mutation),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "guest" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Forbidden",
});
expect((await call(handler, "restricted", {})).isError).toBe(true);
expect(mutation).not.toHaveBeenCalled();
});
test("isolates authenticated callers when reusing catalog schemas", async () => {
const router = trpc.router({ who: exposed.query(({ ctx }) => ctx.user) });
const { tools } = extractMcpToolsFromProcedures(router);
const handlers = ["alice", "bob"].map((user) =>
createMcpProtocolHandler({
caller: router.createCaller({ user }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Failed",
}),
);
const results = await Promise.all(handlers.map((handler) => call(handler, "who", {})));
expect(results.map((result) => result.content)).toEqual([
[{ type: "text", text: '"alice"' }],
[{ type: "text", text: '"bob"' }],
]);
});
});
+21 -4
View File
@@ -1,6 +1,5 @@
import { createMcpHandler, fromJsonSchema, McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { callMcpTool } from "@homarr/api/mcp";
import type { McpTool } from "@homarr/api/mcp";
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
@@ -16,6 +15,15 @@ interface CreateMcpProtocolHandlerOptions {
onProtocolError?: (error: Error) => void;
}
const resolveProcedure = (caller: unknown, path: string[]) =>
path.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
export const createMcpProtocolHandler = ({
caller,
tools,
@@ -44,11 +52,20 @@ export const createMcpProtocolHandler = ({
tool.name,
{
description: tool.description,
inputSchema: fromJsonSchema(tool.inputSchema as Parameters<typeof fromJsonSchema>[0]),
inputSchema: tool.inputValidator,
},
async (args) => {
try {
const result = await callMcpTool(caller, tool, args);
const procedure = resolveProcedure(caller, tool.pathInRouter);
if (typeof procedure !== "function") {
return {
content: [{ type: "text" as const, text: `Tool "${tool.name}" is not callable` }],
isError: true,
};
}
const input = Object.keys(args).length > 0 ? args : undefined;
const result = await (procedure as (value: unknown) => Promise<unknown>)(input);
return {
content: [
{
@@ -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>
);
-2
View File
@@ -51,13 +51,11 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@kubernetes/client-node": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"ajv": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"react": "catalog:",
+62 -115
View File
@@ -1,5 +1,3 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import { addFormats, AjvJsonSchemaValidator } from "@modelcontextprotocol/server/validators/ajv";
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import { z } from "zod/v4";
@@ -17,35 +15,23 @@ export interface McpTool {
type: "query" | "mutation";
pathInRouter: string[];
inputSchema: z.core.JSONSchema.JSONSchema;
inputMode: "none" | "object";
inputValidator: z.ZodObject;
}
export interface McpToolDiagnostic {
name: string;
path: string;
reason: "unsupported_input" | "invalid_schema" | "duplicate_name" | "invalid_name";
}
const emptyInput = z.object({});
export interface McpToolCatalog {
tools: McpTool[];
diagnostics: McpToolDiagnostic[];
}
const emptyInput = z.strictObject({});
const schemaEngine = new Ajv2020({
strict: false,
validateSchema: true,
coerceTypes: false,
useDefaults: false,
removeAdditional: false,
});
addFormats(schemaEngine);
const schemaValidator = new AjvJsonSchemaValidator(schemaEngine);
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
const getObjectInput = (input: unknown): z.ZodObject | null => {
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
const getObjectInput = (input: z.ZodType) => {
if (input instanceof z.ZodObject) return input;
if (input instanceof z.ZodOptional) return getObjectInput(input.unwrap());
if (input instanceof z.ZodOptional) {
const unwrappedInput = input.unwrap();
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
}
return null;
};
@@ -64,110 +50,71 @@ const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
return result;
};
const getProcedureInput = (inputs: z.ZodType[]) => {
if (inputs.length === 0) return emptyInput;
if (inputs.length === 1) {
const input = inputs[0] ?? emptyInput;
return getObjectInput(input) ?? input;
}
const objectInputs = inputs.map(getObjectInput);
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
return null;
};
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
const properties = schema.properties ?? {};
const required = (schema.required ?? []).filter((key) => {
const property = properties[key];
return !property || typeof property !== "object" || !("default" in property);
});
return {
...schema,
type: "object",
properties,
required,
};
};
export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
router: Router<TRoot, TRecord>,
): McpToolCatalog {
): McpTool[] {
const tools: McpTool[] = [];
const diagnostics: McpToolDiagnostic[] = [];
const names = new Map<string, string[]>();
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
if (!isProcedure(procedure)) continue;
const definition = procedure["_def"];
if (definition.type === "subscription") continue;
const metadata = getMcpMetadata(definition.meta);
if (!metadata?.enabled) continue;
const name = metadata.name ?? path.replaceAll(".", "_");
const paths = names.get(name) ?? [];
paths.push(path);
names.set(name, paths);
if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(name)) {
diagnostics.push({ name, path, reason: "invalid_name" });
continue;
}
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
const procedureInput = getProcedureInput(inputs);
const inputs = definition.inputs;
let inputMode: McpTool["inputMode"] = "object";
if (inputs.every((input) => input instanceof z.ZodUndefined || input instanceof z.ZodVoid)) inputMode = "none";
const objectInputs = inputs.map(getObjectInput);
if (inputMode === "object" && !objectInputs.every((input) => input !== null)) {
diagnostics.push({ name, path, reason: "unsupported_input" });
continue;
if (
procedureInput === null ||
(!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success)
) {
console.warn(`[MCP] Procedure ${path} does not use an object input schema; using an empty schema`);
}
const inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
try {
let input: z.ZodType = emptyInput;
if (inputMode === "object") {
const objects = objectInputs.filter((schema): schema is z.ZodObject => schema !== null);
input = objects[0] ?? emptyInput;
for (const object of objects.slice(1)) input = z.intersection(input, object);
}
const inputSchema = z.toJSONSchema(input, {
io: "input",
override: ({ zodSchema, jsonSchema }) => {
if (zodSchema["_zod"].def.type !== "string") return;
if (zodSchema["_zod"].def.checks?.some((check) => check["_zod"].def.check === "overwrite")) {
// trim/case normalization runs before these checks in tRPC. They cannot constrain raw JSON.
delete jsonSchema.minLength;
delete jsonSchema.maxLength;
delete jsonSchema.pattern;
delete jsonSchema.allOf;
delete jsonSchema.format;
}
// Zod's ISO regex permits minute precision; JSON Schema date-time requires seconds.
if (jsonSchema.format === "date-time" && typeof jsonSchema.pattern === "string") {
delete jsonSchema.format;
}
},
});
// Chained tRPC object inputs produce allOf; MCP requires an explicit object root.
if (inputSchema.type !== undefined && inputSchema.type !== "object") throw new Error("Invalid root");
inputSchema.type = "object";
// Zod and the SDK disagree on JSON Schema vocabulary types; compilation checks the actual schema.
schemaValidator.getValidator(inputSchema as Parameters<typeof schemaValidator.getValidator>[0]);
tools.push({
name,
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputMode,
inputSchema,
});
} catch {
// Conversion errors can contain schema metadata. Only expose a stable, safe reason.
diagnostics.push({ name, path, reason: "invalid_schema" });
}
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
z.toJSONSchema(inputValidator, {
io: "input",
unrepresentable: "any",
}),
),
});
}
const duplicates = new Set<string>();
for (const [name, paths] of names) {
if (paths.length < 2) continue;
duplicates.add(name);
for (const path of paths) diagnostics.push({ name, path, reason: "duplicate_name" });
}
return {
tools: tools
.filter((tool) => !duplicates.has(tool.name))
.toSorted((left, right) => left.name.localeCompare(right.name)),
diagnostics: diagnostics.toSorted(
(left, right) => left.path.localeCompare(right.path) || left.reason.localeCompare(right.reason),
),
};
}
/** Dispatch raw JSON through the caller so tRPC owns parsing, transforms, and authorization. */
export async function callMcpTool(caller: unknown, tool: McpTool, input: unknown) {
let procedure = caller;
for (const segment of tool.pathInRouter) {
if ((typeof procedure !== "object" && typeof procedure !== "function") || procedure === null) {
throw new Error("Tool is not callable");
}
procedure = (procedure as Record<string, unknown>)[segment];
}
if (typeof procedure !== "function") throw new Error("Tool is not callable");
let procedureInput = input;
if (tool.inputMode === "none") procedureInput = undefined;
else if (input === undefined) procedureInput = {};
return await (procedure as (value: unknown) => Promise<unknown>)(procedureInput);
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
}
+1 -1
View File
@@ -1,5 +1,5 @@
export { createTRPCContext } from "./trpc";
export { callMcpTool, extractMcpToolsFromProcedures } from "./mcp-tools";
export { extractMcpToolsFromProcedures } from "./mcp-tools";
export type { McpTool } from "./mcp-tools";
import { createTRPCRouter } from "./trpc";
+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,
};
}),
});
+8 -128
View File
@@ -1,12 +1,9 @@
import { initTRPC } from "@trpc/server";
import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import { mcpRouter } from "../mcp";
import type { McpMeta } from "../mcp-tools";
import { createMcpProtocolHandler } from "../../../../apps/nextjs/src/app/api/mcp/_protocol";
import { callMcpTool, extractMcpToolsFromProcedures } from "../mcp-tools";
import { extractMcpToolsFromProcedures } from "../mcp-tools";
vi.mock("@homarr/auth", () => ({}));
@@ -160,7 +157,7 @@ function actualToolInventory() {
{ _def?: { type?: "query" | "mutation" } }
>;
return extractMcpToolsFromProcedures(mcpRouter)
.tools.map((tool) => {
.map((tool) => {
const procedure = procedures[tool.pathInRouter.join(".")];
const type = procedure?.["_def"]?.type;
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
@@ -178,7 +175,7 @@ describe("production MCP router", () => {
});
test("keeps every secret-bearing tool behind a mutation procedure", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
const tools = extractMcpToolsFromProcedures(mcpRouter);
const procedures = mcpRouter["_def"].procedures as unknown as Record<
string,
{ _def?: { type?: "query" | "mutation" } }
@@ -192,7 +189,7 @@ describe("production MCP router", () => {
});
test("gives every production tool a description", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
const tools = extractMcpToolsFromProcedures(mcpRouter);
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
"Get the current Custom Widget authoring instructions.",
@@ -200,17 +197,16 @@ describe("production MCP router", () => {
});
});
test("MCP tools are deterministically ordered and have valid production schemas", () => {
expect(extractMcpToolsFromProcedures(mcpRouter).diagnostics).toEqual([]);
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
test("MCP tools are deterministically ordered and retain executable schemas", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
expect(tools.every((tool) => tool.inputSchema.type === "object")).toBe(true);
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
});
test("publishes both Custom Widget template input formats", () => {
const tool = extractMcpToolsFromProcedures(mcpRouter).tools.find(
const tool = extractMcpToolsFromProcedures(mcpRouter).find(
(candidate) => candidate.name === "customWidget_validateTemplate",
);
@@ -359,119 +355,3 @@ describe("custom widget authoring procedure access", () => {
);
});
});
describe("MCP catalog diagnostics", () => {
const trpc = initTRPC.meta<McpMeta>().create();
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
test("omits unsupported parsers and malformed schemas without losing valid tools", () => {
const router = trpc.router({
good: exposed.input(z.object({ name: z.string() })).query(() => "ok"),
date: exposed.input(z.object({ date: z.date() })).query(() => "unused"),
scalar: exposed.input(z.string()).query(() => "unused"),
parser: exposed.input({ parse: (value: unknown) => value }).query(() => "unused"),
malformed: exposed
.input(z.object({ value: z.string().meta({ type: "invalid-json-type" }) }))
.query(() => "unused"),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
expect(catalog.diagnostics).toEqual([
{ name: "date", path: "date", reason: "invalid_schema" },
{ name: "malformed", path: "malformed", reason: "invalid_schema" },
{ name: "parser", path: "parser", reason: "unsupported_input" },
{ name: "scalar", path: "scalar", reason: "unsupported_input" },
]);
});
test("omits all duplicate names even when one procedure has an invalid schema", () => {
const shared = trpc.procedure.meta({ mcp: { enabled: true, name: "shared" } });
const catalog = extractMcpToolsFromProcedures(
trpc.router({
first: shared.query(() => "first"),
second: shared.input(z.object({ date: z.date() })).query(() => "second"),
good: exposed.query(() => "good"),
invalidName: trpc.procedure.meta({ mcp: { enabled: true, name: "bad name" } }).query(() => "unused"),
}),
);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
expect(catalog.diagnostics.filter((diagnostic) => diagnostic.reason === "duplicate_name")).toEqual([
{ name: "shared", path: "first", reason: "duplicate_name" },
{ name: "shared", path: "second", reason: "duplicate_name" },
]);
expect(catalog.diagnostics).toContainEqual({ name: "bad name", path: "invalidName", reason: "invalid_name" });
});
test("distinguishes empty object input from no input for shared Assistant dispatch", async () => {
const router = trpc.router({
none: exposed.query(({ input }) => input),
explicitNone: exposed.input(z.undefined()).query(({ input }) => input),
object: exposed.input(z.object({ value: z.string().default("default") })).query(({ input }) => input),
optional: exposed
.input(z.object({ value: z.string().default("default") }).optional())
.query(({ input }) => input),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
for (const tool of catalog.tools) {
const result = await callMcpTool(router.createCaller({}), tool, {});
if (tool.inputMode === "none") expect(result).toBeUndefined();
else expect(result).toEqual({ value: "default" });
}
});
});
test.each([false, true])(
"production catalog registers through the real protocol handler (modern=%s)",
async (modern) => {
const { tools, diagnostics } = extractMcpToolsFromProcedures(mcpRouter);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: {},
tools,
version: "test",
instructions: "test",
formatToolError: () => "Tool failed",
});
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
};
const params: Record<string, unknown> = {};
if (modern) {
headers["Mcp-Method"] = "tools/list";
headers["Mcp-Name"] = "homarr";
params["_meta"] = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "catalog-test", version: "1" },
"io.modelcontextprotocol/clientCapabilities": {},
};
}
const response = await handler.fetch(
new Request("http://homarr.test/api/mcp", {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params }),
}),
);
expect(response.status).toBe(200);
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
const body = await response.text();
let payload = body;
if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
payload =
body
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6) ?? "";
}
const result = JSON.parse(payload) as {
error?: unknown;
result: { tools: { name: string; inputSchema: unknown }[] };
};
expect(result.error).toBeUndefined();
expect(result.result.tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema }))).toEqual(
tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema })),
);
},
);
-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;
-9
View File
@@ -366,9 +366,6 @@ catalogs:
ai:
specifier: ^7.0.56
version: 7.0.56
ajv:
specifier: 8.18.0
version: 8.18.0
assistant-stream:
specifier: 0.3.34
version: 0.3.34
@@ -1523,9 +1520,6 @@ importers:
'@kubernetes/client-node':
specifier: 'catalog:'
version: 1.4.0(supports-color@10.2.2)
'@modelcontextprotocol/server':
specifier: 'catalog:'
version: 2.0.0
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.101.2(react@19.2.7)
@@ -1541,9 +1535,6 @@ importers:
'@trpc/tanstack-react-query':
specifier: 'catalog:'
version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.18.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2)
ajv:
specifier: 'catalog:'
version: 8.18.0
lodash.clonedeep:
specifier: 'catalog:'
version: 4.5.0
-1
View File
@@ -123,7 +123,6 @@ catalog:
adm-zip: 0.6.0
acorn: ^8.16.0
acorn-jsx: ^5.3.2
ajv: 8.18.0
jszip: ^3.10.1
sql.js: ^1.14.1
babel-plugin-react-compiler: ^1.0.0