Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9699255180 | ||
|
|
2049c6ade0 | ||
|
|
e21a0bb6da | ||
|
|
61f22c8e64 | ||
|
|
6cca757493 | ||
|
|
1102ad8342 | ||
|
|
7c00c18a42 |
No files matched your search
@@ -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]/`
|
||||
|
||||
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
|
||||
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`.
|
||||
@@ -1,13 +1,17 @@
|
||||
---
|
||||
name: documentation-sync
|
||||
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.
|
||||
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.
|
||||
---
|
||||
|
||||
# Documentation Sync
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Map the change
|
||||
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
|
||||
|
||||
| Code change | Documentation target |
|
||||
| ----------------------------------- | -------------------------------------------------------------------- |
|
||||
@@ -40,11 +44,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.
|
||||
- Document options, required integrations, permissions, empty states, and changed behavior that affect setup or use.
|
||||
- Explain only non-obvious option semantics, integration prerequisites, permission constraints, or behavior that the interface does not make clear.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
1. Describe the shipped behavior, not an implementation plan.
|
||||
1. Every passage adds information an advanced user cannot reasonably infer from the interface. Remove obvious, repetitive, or unnecessary prose.
|
||||
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.
|
||||
|
||||
+3
-2
@@ -16,9 +16,10 @@ LOG_LEVEL='info'
|
||||
|
||||
# This is how you can use the sqlite driver:
|
||||
DB_DRIVER='better-sqlite3'
|
||||
# Safe local source-development path. SQLite files are ignored by Git.
|
||||
# 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.
|
||||
# Container images use /appdata/db/db.sqlite by default instead.
|
||||
DB_URL='./db.sqlite'
|
||||
DB_URL=
|
||||
|
||||
# Those are the two ways to use the mysql2 driver:
|
||||
# 1. Using the URL format:
|
||||
|
||||
@@ -16,10 +16,10 @@ permissions:
|
||||
jobs:
|
||||
create-and-merge-pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Discord notification
|
||||
if: ${{ github.event.inputs.send-notifications }}
|
||||
if: ${{ inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
@@ -37,11 +37,22 @@ jobs:
|
||||
branch: next
|
||||
- name: Create pull request
|
||||
id: create-pull-request
|
||||
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'
|
||||
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"
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Discord notification
|
||||
if: ${{ github.event.inputs.send-notifications }}
|
||||
if: ${{ inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
@@ -58,7 +69,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
|
||||
run: |
|
||||
gh pr review --approve --body "Automatically approved by GitHub Action"
|
||||
gh pr review "${{ steps.create-pull-request.outputs.url }}" --approve --body "Automatically approved by GitHub Action"
|
||||
- name: Obtain token
|
||||
id: obtainMergeToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
@@ -82,21 +93,21 @@ jobs:
|
||||
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
||||
|
||||
- name: Merged Discord notification
|
||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
|
||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && 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' && github.event.inputs.send-notifications }}
|
||||
if: ${{ steps.semver.outputs.bump == 'major' && 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() && github.event.inputs.send-notifications
|
||||
if: ${{ failure() && inputs.send-notifications }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
|
||||
@@ -59,7 +59,13 @@ homarr/
|
||||
|
||||
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
|
||||
|
||||
When modifying user-facing code, you MUST also update the corresponding documentation:
|
||||
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:
|
||||
|
||||
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
|
||||
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
|
||||
@@ -99,6 +105,6 @@ When modifying user-facing code, you MUST also update the corresponding document
|
||||
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` — required user-facing documentation updates
|
||||
- `documentation-sync` — documentation for changes users need explained
|
||||
- `mcp-integration` — safe tRPC-to-MCP exposure
|
||||
- `homarr-custom-widget` — safe Custom JSX v2 authoring
|
||||
@@ -15,19 +15,13 @@ 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 is an upgrade, not a brand-new app wearing the same logo. Here is what actually changed.
|
||||
Homarr 2.0 builds on the dashboard you already use. Here is what changed.
|
||||
|
||||

|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
:::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.
|
||||
|
||||
:::
|
||||
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.
|
||||
|
||||
## Custom Widgets v2 and Workshop
|
||||
|
||||
@@ -150,9 +144,8 @@ 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.
|
||||
|
||||
## Before upgrading
|
||||
## Compatibility notes
|
||||
|
||||
- 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.
|
||||
@@ -160,8 +153,7 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
|
||||
|
||||
## Links
|
||||
|
||||
- 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).
|
||||
- Try the [live demo](https://demo.homarr.dev/).
|
||||
- See [installation and update instructions](/docs/getting-started/installation/docker).
|
||||
- Browse [Workshop](https://homarr.dev/workshop).
|
||||
- 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://app-v2.preview.homarr.dev/
|
||||
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://workshop.example.com/workshop
|
||||
```
|
||||
|
||||
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
|
||||
|
||||
@@ -31,9 +31,13 @@ pnpm install --frozen-lockfile
|
||||
### Run the Homarr application
|
||||
|
||||
1. Copy `.env.example` to `.env`.
|
||||
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.
|
||||
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.
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
@@ -57,6 +61,20 @@ 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).
|
||||
|
||||
@@ -80,8 +98,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 .` — build a production Docker image.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
|
||||
- `docker build -t homarr:local .` — build a production Docker image.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:local` — run it.
|
||||
|
||||
## CI and focused validation
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ 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/)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [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`
|
||||
- [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 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.
|
||||
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.
|
||||
</li>
|
||||
<li>
|
||||
Run `openssl rand -hex 32` twice and set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
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: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,9 @@
|
||||
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,12 +24,14 @@ 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 calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
|
||||
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.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
@@ -40,7 +42,7 @@ WUD does not require authentication by default. If your instance is protected wi
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
|
||||
"Use your WUD account credentials; older versions can use a configured WUD_AUTH_BASIC_* user",
|
||||
"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.
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -18,6 +18,10 @@ 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.
|
||||
|
||||

|
||||
|
||||
## Groups
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
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} />
|
||||
@@ -0,0 +1,37 @@
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
+2
-1
@@ -2304,7 +2304,8 @@
|
||||
"traefik",
|
||||
"customApi",
|
||||
"assistant",
|
||||
"wud"
|
||||
"wud",
|
||||
"llamacpp"
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -5,10 +5,9 @@ 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 { useModalAction } from "@homarr/modals";
|
||||
import { useConfirmModal, 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";
|
||||
|
||||
@@ -40,6 +39,7 @@ 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,14 +143,21 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<InlineConfirmMenuItem
|
||||
<Menu.Item
|
||||
color="red"
|
||||
confirmLabel={tItem("remove.message")}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onConfirm={() => removeItem({ itemId: item.id })}
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
openConfirmModal({
|
||||
title: tItem("remove.message"),
|
||||
children: label,
|
||||
labels: { confirm: tItem("action.remove") },
|
||||
onConfirm: () => removeItem({ itemId: item.id }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.remove")}
|
||||
</InlineConfirmMenuItem>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -454,7 +454,7 @@ export const userRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: { email: true, provider: true },
|
||||
columns: { name: true, email: true, provider: true },
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
|
||||
@@ -472,6 +472,13 @@ 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;
|
||||
@@ -497,6 +504,13 @@ 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({
|
||||
@@ -519,6 +533,13 @@ 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) {
|
||||
|
||||
@@ -46,4 +46,5 @@ 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)),
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -505,6 +505,7 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
|
||||
| "together"
|
||||
| "ollama"
|
||||
| "lm-studio"
|
||||
| "llama-cpp"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull()
|
||||
|
||||
@@ -503,6 +503,7 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
|
||||
| "together"
|
||||
| "ollama"
|
||||
| "lm-studio"
|
||||
| "llama-cpp"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull()
|
||||
|
||||
@@ -488,6 +488,7 @@ export const assistantConfigurations = sqliteTable("assistant_configuration", {
|
||||
| "together"
|
||||
| "ollama"
|
||||
| "lm-studio"
|
||||
| "llama-cpp"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull()
|
||||
|
||||
@@ -11,6 +11,7 @@ export const assistantProviderIds = [
|
||||
"together",
|
||||
"ollama",
|
||||
"lm-studio",
|
||||
"llama-cpp",
|
||||
"custom",
|
||||
] as const;
|
||||
|
||||
@@ -156,6 +157,15 @@ 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,6 +333,7 @@ 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"
|
||||
@@ -390,4 +391,5 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/traefik"
|
||||
| "/docs/widgets/custom-api"
|
||||
| "/docs/widgets/assistant"
|
||||
| "/docs/widgets/whats-up-docker";
|
||||
| "/docs/widgets/whats-up-docker"
|
||||
| "/docs/widgets/llama-cpp";
|
||||
@@ -60,4 +60,5 @@ export const widgetDocSlugs = {
|
||||
customApi: "custom-api",
|
||||
assistant: "assistant",
|
||||
wud: "whats-up-docker",
|
||||
llamacpp: "llama-cpp",
|
||||
} satisfies Record<WidgetKind, string | null>;
|
||||
@@ -545,6 +545,14 @@ 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",
|
||||
@@ -713,6 +721,7 @@ export const integrationCategories = [
|
||||
"uptimeMonitoring",
|
||||
"subtitleManager",
|
||||
"reverseProxy",
|
||||
"systemMonitoring",
|
||||
] as const;
|
||||
|
||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||
@@ -48,6 +48,7 @@ describe("widget integration config", () => {
|
||||
archiveTeamWarrior: 1,
|
||||
anchorNote: 1,
|
||||
wud: 1,
|
||||
llamacpp: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,7 @@ 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;
|
||||
|
||||
@@ -58,6 +58,7 @@ export const widgetKinds = [
|
||||
"customApi",
|
||||
"assistant",
|
||||
"wud",
|
||||
"llamacpp",
|
||||
] as const;
|
||||
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
@@ -110,6 +110,8 @@ 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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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";
|
||||
@@ -95,6 +96,7 @@ 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";
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,489 @@
|
||||
// @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,6 +11,7 @@ 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,
|
||||
@@ -352,4 +353,33 @@ 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("does not send credentials over a non-HTTPS URL", async () => {
|
||||
test("sends configured Basic auth credentials over an HTTP 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.example.com",
|
||||
url: "http://wud:3000",
|
||||
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).toBeUndefined();
|
||||
expect(headers.Authorization).toBe(`Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,15 +238,16 @@ describe("WudIntegration testing endpoint", () => {
|
||||
fetchAsync: vi.fn().mockResolvedValue(response),
|
||||
}) as unknown as IntegrationTestingInput;
|
||||
|
||||
test("returns success when /api/app responds 200", async () => {
|
||||
const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }));
|
||||
test("returns success when /api/containers responds 200", async () => {
|
||||
const input = makeInput(new Response(JSON.stringify(sampleContainersResponse), { 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/app responds non-OK", async () => {
|
||||
test("returns a status-code failure when /api/containers responds non-OK", async () => {
|
||||
const input = makeInput(new Response("unauthorized", { status: 401 }));
|
||||
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
@@ -255,7 +256,7 @@ describe("WudIntegration testing endpoint", () => {
|
||||
});
|
||||
|
||||
test("passes the Basic auth header through to fetchAsync when configured", async () => {
|
||||
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
|
||||
const response = new Response(JSON.stringify(sampleContainersResponse), { 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/app"), {
|
||||
const response = await input.fetchAsync(this.url("/api/containers"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
@@ -23,10 +23,10 @@ export class WudIntegration extends Integration {
|
||||
}
|
||||
|
||||
try {
|
||||
await response.json();
|
||||
await parseWudContainersResponseAsync(response);
|
||||
} catch (error) {
|
||||
return TestConnectionError.ParseResult(
|
||||
new ParseError("Invalid WUD app response", {
|
||||
new ParseError("Invalid WUD containers response", {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
}),
|
||||
);
|
||||
@@ -55,10 +55,6 @@ 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",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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();
|
||||
},
|
||||
});
|
||||
@@ -1027,7 +1027,8 @@
|
||||
"analytics": "分析",
|
||||
"vpn": "VPN",
|
||||
"mediaLibrary": "媒体库",
|
||||
"uptimeMonitoring": "运行时间监控"
|
||||
"uptimeMonitoring": "运行时间监控",
|
||||
"systemMonitoring": "系统监控"
|
||||
},
|
||||
"testConnection": {
|
||||
"action": {
|
||||
@@ -1355,6 +1356,10 @@
|
||||
"bazarr": {
|
||||
"name": "Bazarr",
|
||||
"description": "Sonarr 和 Radarr 的配套应用,用于管理和下载字幕"
|
||||
},
|
||||
"llamacpp": {
|
||||
"name": "llama.cpp",
|
||||
"description": "监控本地 llama.cpp llama-server:健康状态、模型与生成指标"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
@@ -4171,6 +4176,81 @@
|
||||
"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": {
|
||||
|
||||
@@ -1424,7 +1424,8 @@
|
||||
"vpn": "VPN",
|
||||
"mediaLibrary": "Media Library",
|
||||
"uptimeMonitoring": "Uptime Monitoring",
|
||||
"beszel": "Beszel"
|
||||
"beszel": "Beszel",
|
||||
"systemMonitoring": "System Monitoring"
|
||||
},
|
||||
"testConnection": {
|
||||
"action": {
|
||||
@@ -1716,6 +1717,10 @@
|
||||
"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": {
|
||||
@@ -5205,6 +5210,81 @@
|
||||
"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": {
|
||||
@@ -6157,6 +6237,9 @@
|
||||
"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."
|
||||
|
||||
@@ -822,7 +822,8 @@
|
||||
"analytics": "分析",
|
||||
"vpn": "VPN",
|
||||
"mediaLibrary": "媒體庫",
|
||||
"uptimeMonitoring": "運作時間監控"
|
||||
"uptimeMonitoring": "運作時間監控",
|
||||
"systemMonitoring": "系統監控"
|
||||
},
|
||||
"testConnection": {
|
||||
"action": {
|
||||
@@ -1150,6 +1151,10 @@
|
||||
"bazarr": {
|
||||
"name": "Bazarr",
|
||||
"description": "Sonarr 和 Radarr 的配套應用程式,管理和下載字幕"
|
||||
},
|
||||
"llamacpp": {
|
||||
"name": "llama.cpp",
|
||||
"description": "監控本地 llama.cpp llama-server:健康狀態、模型與生成指標"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
@@ -3946,6 +3951,81 @@
|
||||
"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": {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
IconClock,
|
||||
IconClockPlay,
|
||||
IconCloud,
|
||||
IconCpu,
|
||||
IconDeviceCctv,
|
||||
IconDeviceGamepad,
|
||||
IconDownload,
|
||||
@@ -116,4 +117,5 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
traefik: IconRoute,
|
||||
customApi: IconApi,
|
||||
wud: IconBrandDocker,
|
||||
llamacpp: IconCpu,
|
||||
};
|
||||
@@ -0,0 +1,571 @@
|
||||
"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>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
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,6 +14,7 @@ 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 },
|
||||
{
|
||||
|
||||
@@ -59,6 +59,7 @@ 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.
|
||||
@@ -122,6 +123,7 @@ 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 = {
|
||||
@@ -184,6 +186,7 @@ export type WidgetImports = {
|
||||
customApi: typeof customApi;
|
||||
weather: typeof weather;
|
||||
wud: typeof wud;
|
||||
llamacpp: typeof llamacpp;
|
||||
};
|
||||
|
||||
type WidgetComponentLoaders = {
|
||||
@@ -253,6 +256,7 @@ 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;
|
||||
|
||||
Reference in new issue
Block a user