Compare commits

..
Author SHA1 Message Date
Thomas c39a8854aa feat: add authenticated integration request MCP tool (#6835) 2026-09-13 11:05:23 +02:00
Thomas c32a5317ca fix: remove apps multi-select toggle (#6834) 2026-09-12 16:34:03 +02:00
Thomas f18033ddaa fix: sync dev fixes into v2 and improve regression reports (#6821)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: homarr-update-contributors[bot] <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
Co-authored-by: homarr-crowdin[bot] <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Closes #6604
2026-09-12 15:31:04 +02:00
Thomas ff2b589380 fix(board): refine container spacing and collapse layout (#6833) 2026-09-12 15:23:50 +02:00
Thomas 9bc3802b3b fix(wud): authenticate HTTP requests and validate container access (#6831) 2026-09-12 10:57:36 +02:00
Thomas 2049c6ade0 fix: tighten v2 setup and release safeguards (#6824) 2026-09-12 10:47:35 +02:00
ajnart e21a0bb6da fix: refresh OpenAPI widget kinds 2026-09-12 10:46:39 +02:00
Thomas 61f22c8e64 fix: show board removal confirmation in a dialog (#6829) 2026-09-12 10:36:36 +02:00
leafyelin 6cca757493 feat(plugins): add llama.cpp server integration and widget (#6765) 2026-09-10 20:06:13 +02:00
Thomas 1102ad8342 fix: protect demo users from deletion and password changes (#6819) 2026-09-10 19:52:47 +02:00
136 changed files with 28388 additions and 23688 deletions

No files matched your search

+1 -1
View File
@@ -37,4 +37,4 @@ Orient from the current checkout before editing. Treat package manifests, export
- Cron jobs: `packages/cron-jobs/src/jobs/`
- App routes: `apps/nextjs/src/app/[locale]/`
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`.
+9 -5
View File
@@ -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
View File
@@ -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:
+13
View File
@@ -25,6 +25,18 @@ body:
attributes:
label: Additional information
description: Add any other information related to the bug here, screenshots if applicable.
- type: input
id: exactBuild
attributes:
label: Exact build or image
description: For preview or development builds, include the commit SHA or image digest. A moving tag such as v2 is not enough to identify the build.
placeholder: "Commit SHA, image digest, or release version"
- type: input
id: lastWorkingVersion
attributes:
label: Last working version
description: If this worked before, include the last version or build where you tried the same action. Leave blank if unknown.
placeholder: "Version or commit SHA"
- type: dropdown
id: version
attributes:
@@ -33,6 +45,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.77.0
- 1.76.2
- 1.76.1
- 1.76.0
+19 -8
View File
@@ -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
+13 -3
View File
@@ -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`
@@ -86,7 +92,7 @@ When modifying user-facing code, you MUST also update the corresponding document
- Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Mantine: use the project-scoped MCP server in `.mcp.json` for current v9 APIs, and check `packages/ui/` before creating a new primitive.
- Mantine: use the `mantine` MCP server in `.mcp.json` for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox`, the polymorphic `component` prop, `@mantine/hooks`) and check `packages/ui/` for existing conventions first.
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
@@ -94,11 +100,15 @@ When modifying user-facing code, you MUST also update the corresponding document
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
## MCP servers
`.mcp.json` declares project-scoped MCP servers. Keep only keyless definitions there; credentials belong in local dotfiles. See [Mantine LLM tooling](https://mantine.dev/guides/llms/) for client-specific setup.
## Agent Skills
Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`.
- `codebase-context` — architecture, package boundaries, and shared utilities
- `documentation-sync` — 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
+4
View File
@@ -1,3 +1,7 @@
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
+6 -14
View File
@@ -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.
![A populated Homarr 2.0 dashboard with compact widgets, an Assistant, and an application rail](./img/homarr-v2-dashboard.webp)
<!-- 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
@@ -13,7 +13,8 @@ You can change the user and group by using the `PUID` and `PGID` environment var
Important to note is, that you'll also need to change the permissions for the mounted directories accordingly.
:::info
When starting up an updated version of the container it will have about **10 seconds longer to start** as it has to change the permissions of the app directories
At startup, Homarr updates ownership of its data directories and runtime caches for the selected user and group.
Startup time depends on the amount of persistent data; application code and dependencies do not need ownership changes.
:::
## Without mounted Docker socket
@@ -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.
@@ -28,6 +28,8 @@ import { smartHomeExecuteAutomationWidget } from "@site/docs/widgets/smart-home-
]}
/>
Entity toggles and automation triggers send JSON requests to Home Assistant. A rejected action is shown as a failure.
### Adding the integration
<AddingIntegration />
@@ -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",
};
@@ -30,6 +30,8 @@ import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
]}
/>
Disk temperatures are matched to filesystem partitions, including device names without the `/dev/` prefix.
### Adding the integration
<AddingIntegration />
@@ -19,6 +19,9 @@ import Alert from "@theme/Admonition";
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
Disk space uses each pool's root dataset to account for RAIDZ parity and dataset limits. If dataset space is unavailable,
Homarr falls back to physical pool space, which reads higher on RAIDZ, and keeps reporting pool health.
### Adding the integration
<AddingIntegration />
@@ -23,6 +23,8 @@ import imgSelectApikeyPreset from "./imgs/unraid-select-api-key-preset.png";
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
CPU usage is averaged across logical processors, including SMT threads.
### Adding the integration
<AddingIntegration />
@@ -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",
],
},
@@ -64,3 +64,8 @@ Below you can find instructions for some of the integrations.
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
### Proxmox
For the default Proxmox VE cluster certificates, trust the cluster CA from `/etc/pve/pve-root-ca.pem` on any node.
See [Proxmox certificate management](https://pve.proxmox.com/wiki/Certificate_Management).
@@ -57,5 +57,5 @@ Legacy definitions show **Migration required**. The migration prompt excludes cr
describe the visible behavior separately, import the returned v2 definition, and verify it before removing the archived
version.
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.
+31
View File
@@ -70,6 +70,37 @@ through MCP.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
## Authenticated integration requests
`integration_request` calls API endpoints without a dedicated tool, using stored integration credentials.
Get `integrationId` from `integration_all`. Every method requires **full integration access**: arbitrary GET
endpoints can expose credentials or change state. DELETE additionally requires `confirmed: true` after user confirmation.
Ask your agent to consult the service's official API docs with its browsing tools, or provide the endpoint contract.
For example, using the [Sonarr API docs](https://sonarr.tv/docs/api/#v3):
```json
{ "integrationId": "<Sonarr ID>", "method": "GET", "path": "/api/v3/series" }
```
Deleting series 42 **and its episode files**, after confirmation:
```json
{ "integrationId": "<Sonarr ID>", "method": "DELETE", "path": "/api/v3/series/42?deleteFiles=true", "confirmed": true }
```
Supports GET, POST, PUT, PATCH and DELETE with an optional JSON `body` (10 KiB maximum; not allowed for GET).
Returns `{status, data}` with parsed JSON, text, or `null` for an empty body, including upstream error responses.
Known credentials are redacted. Requests have a 15-second total deadline, a 10-second per-request timeout and a
1 MiB response limit. HTTPS uses Homarr's trusted certificates and hostname exceptions.
Paths follow `new URL(path, integration.url)`: `/api/...` starts at the origin root; `api/...` resolves relative to
the configured URL's directory. Absolute URLs, protocol-relative paths, credentials, fragments and redirects are rejected.
Supported integrations: Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd,
Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud. Other authentication schemes
are rejected. The built-in Assistant's default mutation approval applies to this tool, including GET calls.
## Verify the connection
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
+3 -2
View File
@@ -18,8 +18,9 @@ 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 and password changes are disabled for everyone, including administrators,
even when `DEMO_READ_ONLY=false` allows other changes. This applies to the UI and API.
In demo mode (`DEMO_MODE=true`), user deletion, password changes, and local username changes are disabled for everyone,
including administrators, even when `DEMO_READ_ONLY=false` allows other changes. With demo editing enabled, other
profile preferences remain editable. This applies to the UI and API.
![The Users management page](../img/users.png)
+1 -1
View File
@@ -12,7 +12,7 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The App widget opens an application or website and can show whether Homarr can reach it. Configure the target and status
check from the tile's edit actions.
check from the tile's edit actions. In view mode, right-click uses the browser's link menu; Homarr's widget menu remains available in edit mode.
### Adding the widget
@@ -22,6 +22,8 @@ one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep
preserves its configured columns when the dashboard is scaled. Bookmark cards, icons, text, and gaps keep the same
visual density when a low-column dashboard is enlarged to fill the screen.
Right-click a bookmark link in view mode to use the browser menu, including opening it in a new tab or copying its address.
### Screenshots
import gridIconsOnly from "./img/grid-icons-only.png";
@@ -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} />
+37
View File
@@ -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",
},
],
},
};
@@ -41,6 +41,7 @@ import temperature from "./img/temperature.png";
### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled).
- After a successful toggle, the displayed state refreshes immediately.
- If Homarr cannot refresh an entity, the last known state stays visible but toggling is disabled until the connection recovers.
<WidgetInteractionGuide />
@@ -51,4 +51,6 @@ import withDisplayname from "./img/with-displayname.png";
### Configuration
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
+2 -1
View File
@@ -2304,7 +2304,8 @@
"traefik",
"customApi",
"assistant",
"wud"
"wud",
"llamacpp"
]
},
"options": {
+2 -1
View File
@@ -4,6 +4,7 @@ import { callMcpTool } from "@homarr/api/mcp";
import type { McpTool } from "@homarr/api/mcp";
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
import { serializeMcpToolResult } from "./_serialize-result";
interface CreateMcpProtocolHandlerOptions {
caller: unknown;
@@ -53,7 +54,7 @@ export const createMcpProtocolHandler = ({
content: [
{
type: "text" as const,
text: JSON.stringify(toAssistantToolOutput(result)),
text: serializeMcpToolResult(toAssistantToolOutput(result)),
},
],
};
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { serializeMcpToolResult } from "./_serialize-result";
describe("serializeMcpToolResult", () => {
it("serializes objects as JSON strings", () => {
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
});
it("serializes primitive values", () => {
expect(serializeMcpToolResult("hello")).toBe('"hello"');
expect(serializeMcpToolResult(42)).toBe("42");
expect(serializeMcpToolResult(true)).toBe("true");
});
it("serializes null as the string null", () => {
expect(serializeMcpToolResult(null)).toBe("null");
});
it("serializes void results as the string null so MCP content text is always a string", () => {
expect(serializeMcpToolResult(undefined)).toBe("null");
});
});
@@ -0,0 +1,3 @@
export function serializeMcpToolResult(result: unknown): string {
return JSON.stringify(result) ?? "null";
}
@@ -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>
);
@@ -244,7 +244,13 @@ export const WidgetContextMenu = ({
setMenuOpened(false);
}, []);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
if (
!session ||
!settings.enableRightClickOnWidgets ||
isEditMode ||
item.kind === "app" ||
item.kind === "bookmarks"
) {
return <>{children}</>;
}
@@ -11,6 +11,7 @@ import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { COLLAPSED_SECTION_ROW_COUNT } from "~/components/board/layout";
import { SectionGrid } from "./grid/section-grid";
import { useSectionCollapse } from "./section-collapse";
import { useSectionContext } from "./section-context";
import { useOpenSectionApps } from "./use-open-section-apps";
import classes from "./item.module.css";
@@ -25,6 +26,7 @@ interface Props {
export const BoardContainerSection = ({ section }: Props) => {
const board = useRequiredBoard();
const { section: parentSection } = useSectionContext();
const [isEditMode] = useEditMode();
const t = useI18n("section.container");
const tSection = useI18n("section");
@@ -39,8 +41,26 @@ export const BoardContainerSection = ({ section }: Props) => {
});
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
const labelLeft = 8;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
let labelLeft = 8;
let labelRight = 8;
if (isEditMode) {
// Match the root/nested menu offsets in BoardContainerMenu, plus its width and gap.
labelLeft = 36;
if (parentSection.kind === "container") labelLeft = 68;
} else if (options.showOpenAll) {
labelRight = 40;
}
// Expanded controls sit on the border like the ordinary label, without reserving a header row.
const toggleLayout = isVisuallyCollapsed
? { top: 0, left: 0, w: "100%", h: "100%", maw: "100%" }
: {
top: "calc(var(--mantine-spacing-xs) * -1)",
left: labelLeft,
w: "auto",
h: 20,
maw: `calc(100% - ${labelLeft + labelRight}px)`,
};
const toggleIcon = isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />;
return (
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
@@ -68,24 +88,14 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
top={0}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
px={12}
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
{...toggleLayout}
px={6}
ps={isVisuallyCollapsed ? labelLeft : 6}
pe={isVisuallyCollapsed ? labelRight : 6}
radius="sm"
variant="default"
justify={options.showLabel ? "flex-start" : "center"}
leftSection={
options.showLabel ? (
isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)
) : undefined
}
leftSection={options.showLabel && toggleIcon}
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
@@ -94,13 +104,7 @@ export const BoardContainerSection = ({ section }: Props) => {
data-board-container-label
title={options.showLabel ? label : undefined}
>
{options.showLabel ? (
label
) : isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)}
{options.showLabel ? label : toggleIcon}
</Button>
)}
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
@@ -133,6 +133,21 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
}
.board-grid-placeholder[data-grid-placeholder-type="section"] {
clip-path: none;
background: none;
border: 0;
}
.board-grid-placeholder[data-grid-placeholder-type="section"]::after {
position: absolute;
inset: var(--board-container-inset, 0px);
content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
border: 2px dashed var(--mantine-primary-color-filled);
border-radius: inherit;
}
.board-grid-resize-outline {
z-index: 1002;
clip-path: inset(var(--board-grid-item-inset, 0px));
@@ -136,6 +136,7 @@ export const GridPreviewLayer = ({
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-placeholder-type={targetPlacement.type}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
@@ -10,9 +10,9 @@
while growing during a drag happening anywhere else on the board - any container (not just
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
inside that section's DOM tree, so the stray value cascades straight through and silently
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
unset, not merely inherited. Reset it here so every container can never inherit a stray
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx, which only
kicks in when the property is entirely unset, not merely inherited. Reset it here so every
container can never inherit a stray
value from anywhere above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
.viewport[data-section-kind="container"] {
@@ -62,9 +62,9 @@
overflow: visible;
}
/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
/* SectionGrid fits its content to the same inset in both view and edit mode. */
.staticItem[data-type="section"] > .contentMount {
inset: 0;
inset: var(--board-container-inset, 0px);
}
.staticItem[data-type="item"] > .contentMount {
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
@@ -29,6 +29,9 @@ import { useSectionItems } from "../use-section-items";
import { useBoardGridPortalHost } from "./grid-portal-host";
import classes from "./section-grid.module.css";
const GridContentScaleContext = createContext(1);
const CONTAINER_CARD_INSET = 5;
interface SectionGridProps {
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
columnCount: number;
@@ -50,6 +53,7 @@ export const SectionGrid = ({
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const parentContentScale = useContext(GridContentScaleContext);
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
@@ -143,36 +147,37 @@ export const SectionGrid = ({
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
// Convert that height so the label reserves the same space at every board scale.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
const parentScale = canvasScale * parentContentScale;
const effectiveCanvasScale = Number.isFinite(parentScale) && parentScale > 0 ? parentScale : 1;
// Match the card's inset without changing its persisted grid footprint. Include ancestor
// container zoom so equally sized nested containers keep distinct borders at every depth.
let outerCardInset = 0;
if (section.kind === "container") {
outerCardInset = (2 * CONTAINER_CARD_INSET) / effectiveCanvasScale;
}
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth;
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
// describe the real visible card size in both cases (they equal the non-scrollable values when
// rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
// A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
// nested content uniformly scaled within the remaining height so labels and icons retain their
// proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
: 1;
const logicalWidth = Math.max(1, fullGridWidth - outerCardInset);
const viewportHeight = Math.max(1, fullViewportHeight - outerCardInset);
// Fit square cells into the inset card. Scrollable grids use the visible row count,
// not their full content height. Collapse controls never change this geometry.
let containerContentScale = 1;
if (section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0) {
containerContentScale = Math.max(
0.01,
Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1),
);
}
const contentScale = parentContentScale * containerContentScale;
const effectiveContentScale = effectiveCanvasScale * containerContentScale;
// Compute the combined value in JS rather than a CSS calc() referencing the existing
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
// sequential/temporal order the way normal variables do, so any calc() on this element that
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
const combinedUiScale = calculateBoardUiScale(canvasScale) / contentScale;
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -268,9 +273,9 @@ export const SectionGrid = ({
{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
"--board-grid-content-scale": containerContentScale,
"--board-container-inset": `${CONTAINER_CARD_INSET / effectiveContentScale}px`,
} as CSSProperties
}
data-section-id={section.id}
@@ -289,14 +294,17 @@ export const SectionGrid = ({
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
"--board-canvas-inverse-scale": 1 / effectiveContentScale,
"--board-canvas-ui-scale": combinedUiScale,
} as CSSProperties
}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
<GridContentScaleContext.Provider value={contentScale}>
<SectionContent />
</GridContentScaleContext.Provider>
</Box>
<div ref={editorHostRef} className={classes.editorPortalHost} />
</Box>
@@ -304,9 +312,6 @@ export const SectionGrid = ({
);
};
// Matches the collapsible container toggle's h={24} in container-section.tsx.
const CONTAINER_HEADER_HEIGHT = 24;
const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
+2 -2
View File
@@ -9,7 +9,7 @@ http {
# Route websockets traffic to port 3001
location /websockets {
proxy_pass http://${HOSTNAME}:3001;
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
@@ -18,7 +18,7 @@ http {
# Route all other traffic to port 3000
location / {
proxy_pass http://${HOSTNAME}:3000;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -0,0 +1,204 @@
import { Buffer } from "node:buffer";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { decryptSecret } from "@homarr/common/server";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import { CustomWidgetDomainError, executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions";
import { protectedProcedure } from "../../trpc";
export const integrationRequestSchema = z.object({
integrationId: z.string().min(1).max(128),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
path: z.string().min(1).max(8192),
body: z.json().optional(),
confirmed: z
.boolean()
.optional()
.describe("Required for DELETE after the user confirms the deletion and its effects"),
});
export function resolveIntegrationRequestUrl(baseUrl: string, path: string): URL {
// Reject parser normalization tricks as well as ordinary absolute URLs.
// eslint-disable-next-line no-control-regex -- Reject URL parser control-character normalization.
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//") || /[\\\s\u0000-\u001f\u007f]/u.test(path)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "path must be a relative URL on the integration origin" });
}
try {
const base = new URL(baseUrl);
const target = new URL(path, base);
if (
!["http:", "https:"].includes(base.protocol) ||
base.username ||
base.password ||
target.origin !== base.origin ||
target.username ||
target.password ||
target.hash
)
throw new Error();
return target;
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
message: "path must resolve to the integration origin without credentials or a fragment",
});
}
}
// Match the existing adapters; never guess authentication from secret names alone.
const requestAuth = {
sonarr: "apiKeyHeader",
radarr: "apiKeyHeader",
lidarr: "apiKeyHeader",
readarr: "apiKeyHeader",
prowlarr: "apiKeyHeader",
overseerr: "apiKeyHeader",
jellyseerr: "apiKeyHeader",
seerr: "apiKeyHeader",
immich: "apiKeyHeader",
slskd: "apiKeyHeader",
homeAssistant: "bearer",
coolify: "bearer",
audiobookshelf: "bearer",
speedtestTracker: "bearer",
adGuardHome: "basic",
nextcloud: "basic",
} satisfies Partial<Record<IntegrationKind, string>>;
export function resolveIntegrationRequestAuth(
kind: IntegrationKind,
secrets: CustomWidgetAuthConfig["secrets"],
): CustomWidgetAuthConfig {
if (!(kind in requestAuth)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This integration's authentication is not supported by integration_request",
});
}
const type = requestAuth[kind as keyof typeof requestAuth];
let required = ["apiKey"];
if (type === "basic") required = ["username", "password"];
if (required.some((key) => !secrets.some((secret) => secret.kind === key && secret.value))) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration credentials are incomplete" });
}
return { type, secrets };
}
export function redactIntegrationResponse(data: unknown, secrets: CustomWidgetAuthConfig["secrets"]): unknown {
const values = secrets.map(({ value }) => value).filter(Boolean);
const username = secrets.find(({ kind }) => kind === "username")?.value;
const password = secrets.find(({ kind }) => kind === "password")?.value;
if (username && password) values.push(`${username}:${password}`);
const sensitive = [
...new Set(
values.flatMap((value) => [
value,
JSON.stringify(value).slice(1, -1),
encodeURIComponent(value),
Buffer.from(value).toString("base64"),
]),
),
].toSorted((a, b) => b.length - a.length);
const redact = (value: string) => {
for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]");
return value;
};
const visit = (value: unknown): unknown => {
if (typeof value === "string") return redact(value);
if (
(typeof value === "number" || typeof value === "boolean" || value === null) &&
redact(String(value)) !== String(value)
)
return "[REDACTED]";
if (Array.isArray(value)) return value.map(visit);
if (value !== null && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)]));
}
return value;
};
return visit(data);
}
export const integrationRequestProcedure = protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Make an authenticated HTTP request to a configured integration using its stored credentials. Get integrationId from integration_all. Every method requires hasFullAccess because arbitrary endpoints can expose service credentials or mutate state. DELETE requires confirmed=true after user confirmation, including effects such as deleting files. Returns {status,data}, including upstream error statuses. Use the service's API documentation to choose the method, relative path and optional JSON body; consult official docs with your browsing tools, or ask the user for API docs if unavailable. Absolute URLs and redirects are rejected. 15 second deadline, 1 MiB response, 10 KiB JSON body. Supports Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd, Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud; other authentication schemes are rejected. Never suggest bypassing a permission denial.",
},
})
.input(integrationRequestSchema)
.mutation(async ({ ctx, input }) => {
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, ctx.session.user.id) });
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.integrationId),
with: {
secrets: true,
userPermissions: { where: eq(integrationUserPermissions.userId, ctx.session.user.id) },
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groups.map(({ groupId }) => groupId),
),
},
},
});
// Arbitrary GET endpoints can expose service credentials or mutate state.
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
throw new TRPCError({ code: "FORBIDDEN", message: "Missing integration permission: hasFullAccess" });
}
const targetUrl = resolveIntegrationRequestUrl(integration.url, input.path);
if (input.method === "DELETE" && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
}
if (input.method === "GET" && input.body !== undefined) {
throw new TRPCError({ code: "BAD_REQUEST", message: "GET requests cannot include a body" });
}
// Decrypt only after authorization, using the same snapshot as the URL and permissions.
// Do not log upstream data or retain error causes.
try {
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
const auth = resolveIntegrationRequestAuth(integration.kind, secrets);
let tls: CustomWidgetHttpRequest["tls"];
if (targetUrl.protocol === "https:") {
const [ca, hostnames] = await Promise.all([
getAllTrustedCertificatesAsync(),
getTrustedCertificateHostnamesAsync(),
]);
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
}
const response = await executeCustomWidgetRequest({
baseUrl: integration.url,
targetUrl,
method: input.method,
body: JSON.stringify(input.body),
auth,
tls,
networkScope: "loopback",
kind: "action", // Disable redirects for reads and writes alike; never cache this tool.
timeoutMs: 15_000,
textFallback: true,
});
return { status: response.status, data: redactIntegrationResponse(response.data, secrets) };
} catch (error) {
if (error instanceof TRPCError) throw new TRPCError({ code: error.code, message: error.message });
if (error instanceof CustomWidgetDomainError) {
throw new TRPCError({
code: error.code,
message: "Integration request failed (timeout, response limit, redirect or invalid target)",
});
}
throw new TRPCError({ code: "BAD_GATEWAY", message: "Integration request failed" });
}
});
@@ -42,6 +42,7 @@ import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/va
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
import { integrationRequestProcedure } from "./integration-request";
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
import { mapTestConnectionError } from "./map-test-connection-error";
@@ -49,6 +50,7 @@ const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({
request: integrationRequestProcedure,
getKinds: publicProcedure
.meta({
mcp: {
+8 -1
View File
@@ -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;
+1
View File
@@ -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,
};
}),
});
+1 -1
View File
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
return "http://127.0.0.1:3000";
}
export const trpcPath = "/api/trpc";
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBqzCCAVCgAwIBAgIUdAqiYlhFGtcr9T6hsvClk9u82qUwCgYIKoZIzj0EAwIw
GzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDAgFw0yNjA5MTIxNzUwMTNaGA8y
MTI2MDgxOTE3NTAxM1owGzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDBZMBMG
ByqGSM49AgEGCCqGSM49AwEHA0IABABTFnPW0ov08mlsG71BBDZBDoNE5kA3KkvO
Az0lU6EKjz7E2GFVmamqhVK5hwiaVsq5/mY8FbQ/gFLcz2XvJvmjcDBuMB0GA1Ud
DgQWBBSS9fRJbILG0XNpKxBCmkVXdjlLADAfBgNVHSMEGDAWgBSS9fRJbILG0XNp
KxBCmkVXdjlLADAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQUMBKCEGludGVncmF0
aW9uLnRlc3QwCgYIKoZIzj0EAwIDSQAwRgIhAMpKXcPe+rJdM4pD3ujedAhoL8q3
r5KDdLIRfcB6eBDaAiEA1jvGgVqurMY2EsUu2+6Z2gizxDlO8CNcC2a3S/YJCsU=
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdtCOWMLD3Jpsd+Ly
cVh8NDvA13f3idlev6Da4sCGSD6hRANCAAQAUxZz1tKL9PJpbBu9QQQ2QQ6DROZA
NypLzgM9JVOhCo8+xNhhVZmpqoVSuYcImlbKuf5mPBW0P4BS3M9l7yb5
-----END PRIVATE KEY-----
@@ -0,0 +1,300 @@
import { readFileSync } from "node:fs";
import { X509Certificate } from "node:crypto";
import { createServer as createHttpsServer } from "node:https";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { encryptSecret } from "@homarr/common/server";
import { executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
import { eq } from "@homarr/db";
import { createDb } from "@homarr/db/test";
import { integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import type { IntegrationPermission } from "@homarr/definitions";
import { createTRPCRouter } from "../trpc";
import {
integrationRequestProcedure,
integrationRequestSchema,
redactIntegrationResponse,
resolveIntegrationRequestAuth,
resolveIntegrationRequestUrl,
} from "../router/integration/integration-request";
vi.mock("@homarr/auth", () => ({}));
const trust = vi.hoisted(() => ({
certificates: vi.fn(async (): Promise<string[]> => []),
hostnames: vi.fn(async (): Promise<{ hostname: string; thumbprint: string }[]> => []),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: trust.certificates,
getTrustedCertificateHostnamesAsync: trust.hostnames,
}));
const router = createTRPCRouter({ request: integrationRequestProcedure });
const secret = "test-credential-not-for-output";
const calls: { method: string | undefined; path: string | undefined; auth: string | undefined; body: string }[] = [];
const server = createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += String(chunk);
calls.push({ method: req.method, path: req.url, auth: req.headers["x-api-key"] as string | undefined, body });
if (req.url === "/slow") return;
if (req.url === "/redirect") {
res.writeHead(302, { Location: "/echo" });
res.end();
return;
}
if (req.url === "/large") {
res.end("x".repeat(1024 * 1024 + 1));
return;
}
if (req.url === "/echo") {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ [secret]: [secret, encodeURIComponent(secret), Buffer.from(secret).toString("base64")] }));
return;
}
if (req.url === "/text") {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(`invalid JSON ${secret}`);
return;
}
if (req.method === "DELETE") {
res.writeHead(204);
res.end();
return;
}
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify([{ id: 42, title: "Example series" }]));
});
let baseUrl: string;
beforeAll(async () => {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) => {
if (error) reject(error);
else resolve();
}),
);
});
async function fixture(permission?: IntegrationPermission, setup?: (db: ReturnType<typeof createDb>) => Promise<void>) {
const db = createDb();
await db.insert(users).values([{ id: "reader" }, { id: "other" }]);
await db.insert(integrations).values({ id: "sonarr", name: "Sonarr", kind: "sonarr", url: baseUrl });
await db.insert(integrationSecrets).values({ integrationId: "sonarr", kind: "apiKey", value: encryptSecret(secret) });
// Another user's full permission must never grant this caller access.
await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "other", permission: "full" });
if (permission)
await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "reader", permission });
const session = {
user: { id: "reader", permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
} satisfies Session;
await setup?.(db);
return router.createCaller({ db, session, deviceType: undefined });
}
const get = { integrationId: "sonarr", method: "GET", path: "/api/v3/series" } as const;
describe("integration_request validation", () => {
test.each([
"https://evil.com/x",
"https://example.com/x",
"//evil.com/x",
"\\\\evil.com/x",
"/\\evil.com",
" https://evil.com",
"https:\nevil.com",
"/x#fragment",
])("rejects %s", (path) => {
expect(() => resolveIntegrationRequestUrl("https://example.com/base/", path)).toThrow();
});
test.each(["../series", "../../series", "%2e%2e/series"])("normalizes same-origin traversal %s", (path) => {
expect(resolveIntegrationRequestUrl("https://example.com/base/", path).href).toBe("https://example.com/series");
});
test("preserves URL resolution semantics and query parameters", () => {
expect(resolveIntegrationRequestUrl("https://example.com/base/", "series?id=42").href).toBe(
"https://example.com/base/series?id=42",
);
expect(resolveIntegrationRequestUrl("https://example.com/base/", "/series").pathname).toBe("/series");
});
test("accepts only JSON bodies and the specified methods", () => {
expect(integrationRequestSchema.safeParse({ ...get, body: new Date() }).success).toBe(false);
expect(integrationRequestSchema.safeParse({ ...get, method: "HEAD" }).success).toBe(false);
});
test("resolves supported auth and rejects unsupported or incomplete credentials", () => {
expect(resolveIntegrationRequestAuth("sonarr", [{ kind: "apiKey", value: secret }]).type).toBe("apiKeyHeader");
expect(resolveIntegrationRequestAuth("homeAssistant", [{ kind: "apiKey", value: secret }]).type).toBe("bearer");
expect(
resolveIntegrationRequestAuth("adGuardHome", [
{ kind: "username", value: "user" },
{ kind: "password", value: secret },
]).type,
).toBe("basic");
expect(() => resolveIntegrationRequestAuth("qBittorrent", [])).toThrow("not supported");
expect(() => resolveIntegrationRequestAuth("sonarr", [])).toThrow("incomplete");
});
test.each([42, true, false, null])("redacts echoed primitive credential %s", (value) => {
expect(redactIntegrationResponse(value, [{ kind: "password", value: String(value) }])).toBe("[REDACTED]");
});
test("redacts JSON-escaped credentials embedded in text", () => {
const password = 'quoted"password\\with-newline\n';
const encoded = JSON.stringify(password).slice(1, -1);
expect(
redactIntegrationResponse({ message: `password=${encoded}` }, [{ kind: "password", value: password }]),
).toEqual({ message: "password=[REDACTED]" });
});
test("redacts basic credentials and JSON keys without damaging JSON", () => {
const credentials = Buffer.from(`user:${secret}`).toString("base64");
expect(
redactIntegrationResponse({ [secret]: `Basic ${credentials}` }, [
{ kind: "username", value: "user" },
{ kind: "password", value: secret },
]),
).toEqual({ "[REDACTED]": "Basic [REDACTED]" });
});
});
describe("integration_request with a mock integration server", () => {
test("GET authenticates with stored credentials and full permission", async () => {
const caller = await fixture("full");
await expect(caller.request(get)).resolves.toEqual({ status: 200, data: [{ id: 42, title: "Example series" }] });
expect(calls.at(-1)).toMatchObject({ method: "GET", path: "/api/v3/series", auth: secret });
});
test("keeps credentials and URL from the same integration snapshot", async () => {
const rotated = "credential-for-the-new-origin";
const caller = await fixture("full", async (db) => {
const snapshot = await db.query.integrations.findFirst({
where: eq(integrations.id, "sonarr"),
with: {
secrets: true,
userPermissions: { where: eq(integrationUserPermissions.userId, "reader") },
groupPermissions: true,
},
});
// Simulate a URL/key rotation immediately after the first SELECT completed.
await db.update(integrations).set({ url: "https://new-origin.example" }).where(eq(integrations.id, "sonarr"));
await db.update(integrationSecrets).set({ value: encryptSecret(rotated) });
vi.spyOn(db.query.integrations, "findFirst").mockResolvedValueOnce(snapshot);
});
await caller.request(get);
expect(calls.at(-1)?.auth).toBe(secret);
});
test("uses Homarr's trusted certificates and hostname exceptions without disabling TLS checks", async () => {
// This committed certificate/key pair is only for the loopback mock server.
const cert = readFileSync(new URL("./fixtures/integration-request-cert.pem", import.meta.url), "utf8");
const key = readFileSync(new URL("./fixtures/integration-request-key.pem", import.meta.url), "utf8");
const https = createHttpsServer({ cert, key }, (_req, res) => res.end('{"trusted":true}'));
await new Promise<void>((resolve) => https.listen(0, "127.0.0.1", resolve));
try {
const caller = await fixture("full", async (db) => {
await db.update(integrations).set({ url: `https://127.0.0.1:${(https.address() as AddressInfo).port}` });
});
await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" });
trust.certificates.mockResolvedValue([cert]);
await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" });
trust.hostnames.mockResolvedValue([
{ hostname: "127.0.0.1", thumbprint: new X509Certificate(cert).fingerprint256 },
]);
await expect(caller.request(get)).resolves.toEqual({ status: 200, data: { trusted: true } });
} finally {
trust.certificates.mockResolvedValue([]);
trust.hostnames.mockResolvedValue([]);
https.closeAllConnections();
await new Promise<void>((resolve) => https.close(() => resolve()));
}
});
test("denies GET without full permission even when another user has full access", async () => {
const caller = await fixture();
const before = calls.length;
await expect(caller.request(get)).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Missing integration permission: hasFullAccess",
});
expect(calls).toHaveLength(before);
});
test.each(["GET", "POST", "PUT", "PATCH", "DELETE"] as const)(
"denies %s with only use permission",
async (method) => {
const caller = await fixture("use");
const before = calls.length;
await expect(caller.request({ ...get, method, confirmed: true })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Missing integration permission: hasFullAccess",
});
expect(calls).toHaveLength(before);
},
);
test("interact access does not authorize arbitrary endpoints", async () => {
const caller = await fixture("interact");
await expect(caller.request(get)).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Missing integration permission: hasFullAccess",
});
});
test("DELETE requires confirmation and preserves deleteFiles", async () => {
const caller = await fixture("full");
const input = { ...get, method: "DELETE", path: "/api/v3/series/42?deleteFiles=true" } as const;
const before = calls.length;
await expect(caller.request(input)).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
expect(calls).toHaveLength(before);
await expect(caller.request({ ...input, confirmed: true })).resolves.toEqual({ status: 204, data: null });
expect(calls.at(-1)).toMatchObject({ method: "DELETE", path: input.path, auth: secret });
});
test("sends a JSON body with full access", async () => {
const caller = await fixture("full");
await caller.request({ ...get, method: "PATCH", body: { monitored: false } });
expect(calls.at(-1)?.body).toBe('{"monitored":false}');
});
test.each(["https://evil.com/x", "//evil.com/x"])("rejects %s before HTTP", async (path) => {
const caller = await fixture("full");
const before = calls.length;
await expect(caller.request({ ...get, path })).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(calls).toHaveLength(before);
});
test("never follows a redirect", async () => {
const caller = await fixture("full");
const before = calls.length;
await expect(caller.request({ ...get, path: "/redirect" })).rejects.toMatchObject({ code: "BAD_GATEWAY" });
expect(calls).toHaveLength(before + 1);
});
test("enforces the response and request caps", async () => {
const caller = await fixture("full");
await expect(caller.request({ ...get, path: "/large" })).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" });
await expect(caller.request({ ...get, method: "POST", body: "x".repeat(10 * 1024) })).rejects.toMatchObject({
code: "PAYLOAD_TOO_LARGE",
});
});
test("redacts upstream JSON and text, including error responses", async () => {
const caller = await fixture("full");
await expect(caller.request({ ...get, path: "/echo" })).resolves.toEqual({
status: 400,
data: { "[REDACTED]": ["[REDACTED]", "[REDACTED]", "[REDACTED]"] },
});
await expect(caller.request({ ...get, path: "/text" })).resolves.toEqual({
status: 500,
data: "invalid JSON [REDACTED]",
});
});
test("honors the executor's total deadline without logging credentials", async () => {
const logError = vi.fn();
await expect(
executeCustomWidgetRequest({
baseUrl,
targetUrl: `${baseUrl}/slow`,
method: "GET",
networkScope: "loopback",
kind: "action",
timeoutMs: 30,
auth: { type: "apiKeyHeader", secrets: [{ kind: "apiKey", value: secret }] },
logError,
}),
).rejects.toMatchObject({ code: "BAD_GATEWAY", reason: "timeout" });
expect(JSON.stringify(logError.mock.calls)).not.toContain(secret);
});
});
+1
View File
@@ -87,6 +87,7 @@ const MCP_TOOL_ALLOWLIST = {
"wud_getStats",
],
mutation: [
"integration_request",
"app_create",
"app_delete",
"app_update",
@@ -14,7 +14,6 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import type { TrustedCertificateHostname } from "../certificates/hostnames";
import { withTimeoutAsync } from "./timeout";
export const createCustomCheckServerIdentity = (
trustedHostnames: TrustedCertificateHostname[],
): typeof checkServerIdentity => {
@@ -1,6 +1,7 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import type { LookupFunction } from "node:net";
import type { ConnectionOptions } from "node:tls";
import { Agent } from "undici";
import type { CustomJsxNetworkScope } from "../core";
@@ -191,7 +192,11 @@ export function assertSafeStaticHeaders(headers: Record<string, string> | undefi
}
}
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
export function createPinnedAgent(
addresses: ResolvedAddress[],
timeoutMs: number,
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">,
) {
const customLookup: LookupFunction = (_hostname, options, callback) => {
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
@@ -204,7 +209,7 @@ export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: numbe
else callback(null, selected.address, selected.family);
};
return new Agent({
connect: { lookup: customLookup },
connect: { ...tls, lookup: customLookup },
connectTimeout: timeoutMs,
headersTimeout: timeoutMs,
bodyTimeout: timeoutMs,
@@ -1,4 +1,5 @@
import { Buffer } from "node:buffer";
import type { ConnectionOptions } from "node:tls";
import { STATUS_CODES } from "node:http";
import { Headers, Response } from "undici";
@@ -52,6 +53,9 @@ export interface CustomWidgetHttpRequest {
auth?: CustomWidgetAuthConfig;
networkScope: CustomJsxNetworkScope;
kind: "query" | "action";
timeoutMs?: number;
textFallback?: boolean;
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
cacheKey?: string;
cacheTtlSeconds?: number;
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
@@ -73,7 +77,10 @@ type RequestHopResult =
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
const timeout = setTimeout(
() => controller.abort(),
Math.min(input.timeoutMs ?? MAX_REQUEST_DURATION_MS, MAX_REQUEST_DURATION_MS),
);
try {
return await performRequestWithinDeadline(input, controller.signal);
} catch (error) {
@@ -113,6 +120,7 @@ async function performRequestWithinDeadline(
const dispatcher = createPinnedAgent(
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
REQUEST_TIMEOUT_MS,
input.tls,
);
const headers = buildHeaders(input, currentUrl, currentBody);
const controller = new AbortController();
@@ -141,7 +149,7 @@ async function performRequestWithinDeadline(
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: await parseResponseBody(response),
data: await parseResponseBody(response, input.textFallback),
},
};
} else {
@@ -45,7 +45,7 @@ async function readLimitedBody(response: Response): Promise<string> {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
export async function parseResponseBody(response: Response): Promise<unknown> {
export async function parseResponseBody(response: Response, textFallback = false): Promise<unknown> {
const text = await readLimitedBody(response);
if (!text) return null;
try {
@@ -54,7 +54,7 @@ export async function parseResponseBody(response: Response): Promise<unknown> {
return json;
} catch (error) {
if (error instanceof CustomWidgetDomainError) throw error;
if (response.headers.get("content-type")?.toLowerCase().includes("json")) {
if (!textFallback && response.headers.get("content-type")?.toLowerCase().includes("json")) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
}
return text;
+1
View File
@@ -505,6 +505,7 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
+1
View File
@@ -503,6 +503,7 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
+1
View File
@@ -488,6 +488,7 @@ export const assistantConfigurations = sqliteTable("assistant_configuration", {
| "together"
| "ollama"
| "lm-studio"
| "llama-cpp"
| "custom"
>()
.notNull()
+10
View File
@@ -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>;
+9
View File
@@ -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;
+1
View File
@@ -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;
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
*/
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
headers: {
...this.getAuthHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
method: "POST",
});
+2
View File
@@ -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;
@@ -67,10 +67,12 @@ const ws = vi.hoisted(() => {
void Promise.resolve(responder(payload.method as string, (payload.params as unknown[] | undefined) ?? [])).then(
(result) => {
// An Error mimics a rejected method; promises support independent-request checks.
const body = result instanceof Error ? { error: { message: result.message } } : { result };
const envelope =
payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, result }
: { msg: "result", id: payload.id, result };
? { jsonrpc: "2.0", id: payload.id, ...body }
: { msg: "result", id: payload.id, ...body };
this.respond(JSON.stringify(envelope));
},
);
@@ -101,7 +103,10 @@ const ws = vi.hoisted(() => {
};
});
const logs = vi.hoisted(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }));
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => logs }));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
@@ -158,6 +163,14 @@ const happyResponder = (method: string) => {
];
case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
case "pool.dataset.query":
return [
{
id: "tank",
used: { parsed: 200 },
available: { parsed: 300 },
},
];
case "interface.query":
return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data":
@@ -169,6 +182,11 @@ const happyResponder = (method: string) => {
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
// `pool.query` reports 500 allocated of a 1000 byte pool, leaving 500 free.
const physicalFallbackFileSystem = [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }];
const ERROR_MESSAGE_LIMIT = 200;
let integrationCounter = 0;
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
new TrueNasIntegration({
@@ -190,6 +208,7 @@ describe("TrueNasIntegration", () => {
ws.constructedUrls.length = 0;
ws.failures.length = 0;
ws.setResponder(happyResponder);
logs.warn.mockClear();
});
test("fetches and maps system info over the JSON-RPC API", async () => {
@@ -201,6 +220,18 @@ describe("TrueNasIntegration", () => {
// JSON-RPC does not use the legacy "connect" session handshake.
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
const datasetPayload = ws.sentPayloads.find((payload) => payload.method === "pool.dataset.query");
expect(datasetPayload).toMatchObject({
params: [
[["id", "in", ["tank"]]],
{
extra: {
properties: ["used", "available"],
},
},
],
});
expect(result).toMatchObject({
version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon",
@@ -214,12 +245,131 @@ describe("TrueNasIntegration", () => {
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
// `available` is the free space left on the pool (free = 500), not its total size.
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
// `available` and `used` come from the root dataset, not the physical pool.
fileSystem: [{ deviceName: "tank", available: "300", used: "200", percentage: 40 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
});
test("falls back to physical pool space when the root dataset is not returned", async () => {
ws.setResponder((method) => (method === "pool.dataset.query" ? [] : happyResponder(method)));
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// Dropping the pool would also hide its health status.
expect(result.smart).toMatchObject([{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" }]);
});
test("falls back to physical pool space when the root dataset reports no usable values", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? [{ id: "tank", used: { parsed: null }, available: null }]
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
});
test("falls back to physical pool space when the root dataset omits the requested properties", async () => {
// Datasets that cannot report space, e.g. while locked, come back without `used` and `available`.
ws.setResponder((method) =>
method === "pool.dataset.query" ? [{ id: "tank", locked: true }] : happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
});
test("falls back to physical pool space when the dataset response fails schema validation", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query" ? { unexpected: "not an array" } : happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// A malformed payload must not take the rest of the system info down with it.
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
});
test("falls back to physical pool space when the dataset query is rejected", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? new Error("[EINVAL] filters: Value error, Invalid operation: in")
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// A rejected dataset query must not take the rest of the system info down with it.
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
});
test("logs the truncated rejection reason rather than the generic wrapper message", async () => {
// TrueNAS answers a rejected method with its Python traceback, which runs to several kilobytes.
ws.setResponder((method) =>
method === "pool.dataset.query"
? new Error(`[EINVAL] query-filters ${"x".repeat(5000)}`)
: happyResponder(method),
);
const integration = createIntegration(credentials);
await integration.getSystemInfoAsync();
const logged = logs.warn.mock.calls.find(([message]) => String(message).includes("Could not retrieve"));
const reason = String((logged?.[1] as { error?: string } | undefined)?.error);
// The decorator would otherwise surface only "An unknown error occured while executing Integration method".
expect(reason).toContain("[EINVAL] query-filters");
expect(reason).toHaveLength(ERROR_MESSAGE_LIMIT + 1); // truncated, plus the ellipsis
});
test("keeps reporting a pool that is unhealthy but has no usable dataset space", async () => {
ws.setResponder((method) => {
if (method === "pool.query")
return [
{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 },
{ name: "backup", status: "DEGRADED", healthy: false, free: 10, size: 100, allocated: 90 },
];
if (method === "pool.dataset.query") return [{ id: "tank", used: { parsed: 200 }, available: { parsed: 300 } }];
return happyResponder(method);
});
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.smart).toMatchObject([
{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" },
{ deviceName: "backup", healthy: false, overallStatus: "DEGRADED" },
]);
expect(result.fileSystem).toMatchObject([
{ deviceName: "tank", available: "300", used: "200", percentage: 40 },
{ deviceName: "backup", available: "10", used: "90", percentage: 90 },
]);
});
test("reports zero usage for an empty root dataset", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? [{ id: "tank", used: { parsed: 0 }, available: { parsed: 0 } }]
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem[0]?.percentage).toBe(0);
});
test("starts independent system requests together while preserving the network interface dependency", async () => {
const gates = new Map<string, ReturnType<typeof Promise.withResolvers<unknown>>>();
ws.setResponder((method) => {
@@ -236,10 +386,14 @@ describe("TrueNasIntegration", () => {
expect([...gates.keys()]).toEqual(["system.info", "reporting.get_data", "pool.query", "interface.query"]);
});
expect(gates.has("reporting.netdata_get_data")).toBe(false);
expect(gates.has("pool.dataset.query")).toBe(false);
gates.get("interface.query")?.resolve(happyResponder("interface.query"));
await vi.waitFor(() => expect(gates.has("reporting.netdata_get_data")).toBe(true));
gates.get("pool.query")?.resolve(happyResponder("pool.query"));
await vi.waitFor(() => expect(gates.has("pool.dataset.query")).toBe(true));
for (const [method, gate] of gates) gate.resolve(happyResponder(method));
await expect(resultPromise).resolves.toMatchObject({
@@ -15,6 +15,22 @@ const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100;
const ERROR_MESSAGE_LIMIT = 200;
/**
* The error decorator replaces a rejected request with a generic message and moves the actionable
* reason to `cause`. TrueNAS puts its middleware traceback in there, several kilobytes of it, so the
* reason is truncated before it reaches a log line that repeats on every poll.
*/
const describeRequestError = (error: unknown) => {
const cause = error instanceof Error ? error.cause : undefined;
let reason = String(error);
if (error instanceof Error) reason = error.message;
if (cause instanceof Error) reason = cause.message;
if (reason.length > ERROR_MESSAGE_LIMIT) return `${reason.slice(0, ERROR_MESSAGE_LIMIT)}…`;
return reason;
};
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private client?: TrueNasClient;
@@ -48,12 +64,17 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
memAvailableInBytes,
memUsedInBytes,
fileSystem: datasets.map((dataset) => ({
deviceName: dataset.name,
available: `${dataset.free}`, // free space left on the pool
used: `${dataset.allocated}`,
percentage: (dataset.allocated / dataset.size) * 100,
})),
fileSystem: datasets.map((dataset) => {
const total = dataset.used + dataset.available;
let percentage = 0;
if (total > 0) percentage = (dataset.used / total) * 100;
return {
deviceName: dataset.name,
available: `${dataset.available}`,
used: `${dataset.used}`,
percentage,
};
}),
availablePkgUpdates: 0,
network: {
up: upload * NETWORK_MULTIPLIER,
@@ -108,9 +129,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
name: pool.name,
status: pool.status,
healthy: pool.healthy,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
@@ -118,12 +140,76 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
if (activePools.length === 0) return [];
const usableSpaceByPool = await this.getUsableSpaceByPoolAsync(activePools.map((pool) => pool.name));
return activePools.map((pool) => {
const usableSpace = usableSpaceByPool.get(pool.name);
if (!usableSpace) {
logger.warn("Falling back to physical pool space, the root dataset reported no usable space", {
url: this.integration.url,
pool: pool.name,
});
}
return {
name: pool.name,
status: pool.status,
healthy: pool.healthy,
used: usableSpace?.used ?? pool.allocated,
available: usableSpace?.available ?? pool.free,
};
});
}
/**
* Returns the usable space of each pool's root ZFS dataset, keyed by pool name. Unlike the physical
* `pool.query` values, these account for RAIDZ parity overhead and dataset limits, matching `zfs list`.
* See https://github.com/homarr-labs/homarr/issues/6566
*
* A pool is left out when its usable space is unavailable, so the caller keeps reporting the pool with
* its physical values instead of dropping it, which would also hide its health status:
* - request rejected → method or `extra.properties` unsupported, or the API key lacks the dataset role
* - response rejected → the payload does not match the expected shape
* - dataset missing → the pool's root dataset was not returned by the query
* - property absent → the dataset does not report `used` / `available`, e.g. while locked
*/
private async getUsableSpaceByPoolAsync(poolNames: string[]) {
const usableSpaceByPool = new Map<string, { used: number; available: number }>();
try {
const datasets = await this.requestAsync("pool.dataset.query", [
[["id", "in", poolNames]],
{
extra: {
properties: ["used", "available"],
},
},
]);
for (const dataset of await poolDatasetSchema.parseAsync(datasets)) {
const used = dataset.used?.parsed;
const available = dataset.available?.parsed;
if (typeof used !== "number" || typeof available !== "number") continue;
usableSpaceByPool.set(dataset.id, { used, available });
}
} catch (error) {
logger.warn("Could not retrieve root dataset space, continuing with physical pool space", {
url: this.integration.url,
error: describeRequestError(error),
});
}
return usableSpaceByPool;
}
/**
@@ -264,6 +350,19 @@ const poolSchema = z.array(
}),
);
// Properties are absent or unparsed for datasets that do not report them, e.g. while locked,
// so they are optional here and the pool falls back to its physical values instead of failing
// the whole request, which would also drop cpu, memory and network data
const poolDatasetPropertySchema = z.object({ parsed: z.number().min(0).nullish() }).nullish();
const poolDatasetSchema = z.array(
z.object({
id: z.string(),
used: poolDatasetPropertySchema,
available: poolDatasetPropertySchema,
}),
);
const reportingNetDataSchema = z.array(
z.object({
name: z.string(),
@@ -37,7 +37,11 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
const systemInfo = await this.getSystemInformationAsync();
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
const cpuCount = systemInfo.info.cpu.cores;
const cpuCount = systemInfo.metrics.cpu.cpus.length;
let cpuUtilizationNormalized = 0;
if (cpuCount > 0) {
cpuUtilizationNormalized = cpuUtilization / cpuCount;
}
const totalMemory = systemInfo.metrics.memory.total;
const usedMemory = Math.max(totalMemory - systemInfo.metrics.memory.available, 0);
@@ -46,7 +50,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
return {
version: systemInfo.info.os.release,
cpuModelName: systemInfo.info.cpu.brand,
cpuUtilization: cpuUtilization / cpuCount,
cpuUtilization: cpuUtilizationNormalized,
memUsedInBytes: usedMemory,
memAvailableInBytes: totalMemory - usedMemory,
uptime: dayjs().diff(uptime, "seconds"),
@@ -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,48 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { HomeAssistantIntegration } from "../src";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new HomeAssistantIntegration({
id: "home-assistant",
name: "Home Assistant",
url: "http://home-assistant.local:8123",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "secret" }],
});
describe("HomeAssistantIntegration actions", () => {
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue({ ok: true } as never);
});
test.each([
["toggle", () => integration.triggerToggleAsync("light.desk"), "/api/services/homeassistant/toggle", "light.desk"],
[
"automation",
() => integration.triggerAutomationAsync("automation.turn_on_lights"),
"/api/services/automation/trigger",
"automation.turn_on_lights",
],
])("sends the %s service call as JSON", async (_action, trigger, path, entityId) => {
await expect(trigger()).resolves.toBe(true);
expect(mockFetch).toHaveBeenCalledWith(new URL(`http://home-assistant.local:8123${path}`), {
headers: {
Authorization: "Bearer secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ entity_id: entityId }),
method: "POST",
});
});
});
@@ -0,0 +1,39 @@
// @vitest-environment node
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { getServerStatistics, init } from "@immich/sdk";
import { describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn(async () => []),
getTrustedCertificateHostnamesAsync: vi.fn(async () => []),
}));
describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync", () => {
test("x-api-key set via init() reaches the server", async () => {
let receivedXApiKey: string | string[] | undefined;
const server = createServer((req, res) => {
receivedXApiKey = req.headers["x-api-key"];
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ usage: 1000, photos: 5, videos: 2 }));
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const port = (server.address() as AddressInfo).port;
init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" });
try {
const stats = await getServerStatistics({
fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch,
});
expect(receivedXApiKey).toBe("sdk-configured-key");
expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 });
} finally {
server.close();
}
});
});
@@ -12,7 +12,7 @@ import {
Text,
ThemeIcon,
} from "@mantine/core";
import { IconBulb, IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
import { IconBulb, IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -99,11 +99,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
actions.closeModal();
};
const handleMultiSelectToggle = () => {
if (multiSelectActive) setSelectedAppIds(new Set());
setMultiSelectActive((current) => !current);
};
return (
<Stack gap="md">
<FloatingTip
@@ -132,19 +127,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
}
}}
/>
{multiSelectAvailable && (
<Group justify="flex-end">
<Button
size="compact-sm"
variant={multiSelectActive ? "light" : "subtle"}
leftSection={<IconListCheck size={16} />}
aria-pressed={multiSelectActive}
onClick={handleMultiSelectToggle}
>
{multiSelectActive ? t("app.action.select.cancelMultiple") : t("app.action.select.selectMultiple")}
</Button>
</Group>
)}
</Stack>
{/* Scrollable Container with App Cards */}
+16
View File
@@ -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();
},
});
@@ -38,4 +38,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
return result.data;
},
// The widget already polls every 30 seconds and refetches after toggles.
cacheTtlMs: 0,
});
File diff suppressed because it is too large. Load diff
+272 -146
View File
@@ -1018,16 +1018,17 @@
"releasesProvider": "发布",
"notifications": "通知",
"firewall": "防火墙",
"timetable": "时刻表",
"timetable": "时间表",
"photoService": "照片",
"documents": "文档",
"notes": "笔记",
"mediaMonitoring": "监控",
"mediaMonitoring": "媒体监控",
"speedtest": "速度测试",
"analytics": "分析",
"vpn": "VPN",
"mediaLibrary": "媒体库",
"uptimeMonitoring": "运行时间监控"
"uptimeMonitoring": "运行时间监控",
"systemMonitoring": "系统监控"
},
"testConnection": {
"action": {
@@ -1310,8 +1311,8 @@
"newLabel": "新建私钥"
},
"slug": {
"label": "状态页面标识",
"newLabel": "新的状态页面标识"
"label": "状态页标识",
"newLabel": "新状态页标识"
}
}
},
@@ -1322,7 +1323,7 @@
},
"audiobookshelf": {
"name": "Audiobookshelf",
"description": "自托管有声书和播客服务器"
"description": "自托管有声读物和播客服务器"
},
"navidrome": {
"name": "Navidrome",
@@ -1330,13 +1331,13 @@
},
"uptimeKuma": {
"name": "Uptime Kuma",
"description": "自托管运行时间监控,带有公开状态页",
"description": "自托管运行时间监控,支持公开状态页",
"secret": {
"slug": {
"description": "公开状态页的标识。如果未设置,默认为 \"default\"。"
"description": "您公开状态页的标识。如未设置,默认值为“default”。"
},
"apiKey": {
"description": "用于经过身份验证的 /metrics 访问的可选 API 密钥"
"description": "用于访问 /metrics 认证接口的可选 API 密钥"
}
}
},
@@ -1346,7 +1347,7 @@
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "用于归档和组织扫描文档的文档管理系统"
"description": "用于归档和整理扫描文档的文档管理系统"
},
"patchmon": {
"name": "PatchMon",
@@ -1355,6 +1356,14 @@
"bazarr": {
"name": "Bazarr",
"description": "Sonarr 和 Radarr 的配套应用,用于管理和下载字幕"
},
"llamacpp": {
"name": "llama.cpp",
"description": "监控本地 llama.cpp llama-server:健康状态、模型与生成指标"
},
"wud": {
"name": "What's Up Docker",
"description": "监控您哪些 Docker 容器有可用的镜像更新"
}
},
"media": {
@@ -1398,7 +1407,7 @@
}
},
"noResults": {
"title": "未上传媒体"
"title": "暂无上传的媒体"
}
},
"common": {
@@ -1437,10 +1446,20 @@
"here": "这里",
"iconPicker": {
"label": "图标 URL",
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标。",
"headerLoading": "输入以搜索图标…",
"header": "输入名称或对象来筛选图标… Homarr 将为您搜索 {countIcons} 图标",
"headerLoading": "按名称搜索图像",
"noResults": "没有与“{search}”匹配的图标。",
"error": "无法加载图标。"
"error": "无法加载图标。",
"description": "搜索库,粘贴图片URL,或上传文件。",
"clear": "清除图像",
"uploadImage": "上传图片",
"results": "图像搜索结果",
"localImages": "你的映像",
"svgIcons": "SVG 图标",
"otherImages": "其他映像",
"urlLoading": "正在检查图片…",
"urlReady": "图像已准备好使用",
"urlError": "无法加载此图像。请检查URL,然后重试。"
},
"colorScheme": {
"options": {
@@ -1663,7 +1682,7 @@
"app": "编辑应用"
},
"app": {
"propagationNotice": "对此应用的更改将应用于所有面板中使用它的所有位置。"
"propagationNotice": "对此应用的更改将应用于所有面板中使用该应用的位置。"
},
"advancedOptions": {
"label": "高级选项",
@@ -2486,7 +2505,7 @@
},
"ups": {
"name": "UPS",
"description": "通过 NUT 服务器监控 UPS 设备的状态",
"description": "通过 NUT 服务器监控您的 UPS 设备状态",
"option": {
"showBattery": {
"label": "显示电量"
@@ -3784,35 +3803,35 @@
"option": {
"showArtists": {
"label": "显示艺术家",
"description": "显示音乐库中艺术家的总数 (Navidrome)"
"description": "显示音乐库中的艺术家总数(Navidrome)"
},
"showAlbums": {
"label": "显示专辑",
"description": "显示音乐库中专辑的总数 (Navidrome)"
"description": "显示音乐库中的专辑总数(Navidrome)"
},
"showSongs": {
"label": "显示歌曲",
"description": "显示音乐库中歌曲的总数 (Navidrome)"
"description": "显示音乐库中的歌曲总数(Navidrome)"
},
"showLibraryCount": {
"label": "显示库数量",
"description": "显示实例中的库数量 (Audiobookshelf)"
"label": "显示媒体库数量",
"description": "显示实例中的媒体库数量(Audiobookshelf)"
},
"showAudiobooks": {
"label": "显示有声书",
"description": "显示所有库中的有声书总数 (Audiobookshelf)"
"label": "显示有声读物",
"description": "显示所有媒体库中的有声读物总数(Audiobookshelf)"
},
"showPodcasts": {
"label": "显示播客",
"description": "显示所有库中的播客总数 (Audiobookshelf)"
"description": "显示所有媒体库中的播客总数(Audiobookshelf)"
},
"showListeningTime": {
"label": "显示收听时间",
"description": "显示您的总收听时间 (Audiobookshelf)"
"label": "显示收听时长",
"description": "显示总收听时长(Audiobookshelf)"
},
"showActiveSessions": {
"label": "显示活跃会话",
"description": "显示当前活跃的播放会话数量 (Audiobookshelf)"
"description": "显示当前活跃播放会话的数量(Audiobookshelf)"
},
"compactMode": {
"label": "紧凑模式",
@@ -3822,8 +3841,8 @@
"artists": "艺术家",
"albums": "专辑",
"songs": "歌曲",
"libraryCount": "库",
"audiobooks": "有声书",
"libraryCount": "媒体库",
"audiobooks": "有声读物",
"podcasts": "播客",
"listeningTime": "收听时长",
"activeSessions": "活跃会话",
@@ -3923,10 +3942,10 @@
},
"beszelSystemTable": {
"name": "Beszel 系统(表格)",
"description": "所有 Beszel 监控系统的表格视图,具有可排序列和状态指示器",
"description": "所有 Beszel 监控系统的表格视图,包含可排序列和状态指示器",
"option": {
"statusFilter": {
"label": "状态过滤器"
"label": "状态筛选"
},
"sortBy": {
"label": "排序方式"
@@ -4057,17 +4076,17 @@
}
},
"beszelAlerts": {
"name": "Beszel 警报",
"description": "查看 Beszel 警报配置和历史记录",
"name": "Beszel 告警",
"description": "查看 Beszel 告警配置和历史记录",
"option": {
"showHistory": {
"label": "显示警报历史"
"label": "显示告警历史"
},
"maxHistoryItems": {
"label": "最大历史记录条数"
}
},
"empty": "未配置警报",
"empty": "未配置告警",
"error": {
"internalServerError": "无法连接到 Beszel。请检查您的集成设置。"
},
@@ -4079,15 +4098,15 @@
}
},
"beszelSystemStats": {
"name": "Beszel 系统状态",
"description": "CPU、内存、磁盘、网络和 Docker 容器指标的时间序列图表",
"name": "Beszel 系统统计",
"description": "CPU、内存、磁盘、网络和 Docker 容器指标的时序图表",
"option": {
"systemId": {
"label": "系统",
"description": "选择要显示统计信息的 Beszel 系统"
},
"timePeriod": {
"label": "时间段"
"label": "时间范围"
},
"showCpu": {
"label": "显示 CPU 图表"
@@ -4127,12 +4146,12 @@
"chart": {
"cpu": {
"title": "CPU 使用率",
"subtitle": "平均系统 CPU 利用率",
"subtitle": "系统整体 CPU 平均使用率",
"series": "CPU"
},
"memory": {
"title": "内存使用率",
"subtitle": "记录时的精确利用率",
"subtitle": "记录时间点的精确使用率",
"series": "已用",
"cache": "缓存/缓冲区"
},
@@ -4154,23 +4173,130 @@
},
"network": {
"title": "带宽",
"subtitle": "公共接口的网络流量",
"sent": "已发送",
"subtitle": "公网接口的网络流量",
"sent": "发送",
"recv": "已接收"
},
"dockerCpu": {
"title": "Docker CPU 使用率",
"subtitle": "容器的平均 CPU 利用率"
"subtitle": "容器的平均 CPU 使用率"
},
"dockerMemory": {
"title": "Docker 内存使用率",
"subtitle": "Docker 容器的内存使用"
"subtitle": "Docker 容器的内存使用率"
},
"dockerNetwork": {
"title": "Docker 网络 I/O",
"subtitle": "Docker 容器的网络流量"
}
}
},
"llamacpp": {
"name": "llama.cpp",
"description": "显示本地 llama.cpp 服务器的健康状态、加载模型和生成速度。",
"title": "llama.cpp",
"status": {
"unhealthy": "异常 ({status})"
},
"idle": "空闲",
"speedTooltip": {
"current": "当前生成速度",
"average": "启动以来的平均生成速度",
"request": "本次请求开始以来的平均速度"
},
"busy": "{count, plural, other {# 个请求处理中}}",
"modelInfo": {
"context": "上下文",
"size": "文件大小",
"parameters": "参数量",
"model": "模型",
"quantization": "量化"
},
"stats": {
"tokensProcessed": "已处理 Token",
"contextUsage": "上下文 / KV 占用",
"cacheHitRate": "提示缓存命中率",
"tokensGenerated": "已生成 Token"
},
"error": {
"unreachable": "无法连接 llama-server"
},
"unit": {
"speed": "{value} t/s",
"parameters": "{value}B",
"none": "—",
"speedShort": "t/s"
},
"option": {
"showTitle": {
"label": "显示标题"
},
"showModelInfo": {
"label": "显示模型信息"
},
"showContextUsage": {
"label": "显示上下文占用"
},
"showCacheHitRate": {
"label": "显示缓存命中率"
},
"showSpeedStats": {
"label": "显示速度统计"
},
"showRequests": {
"label": "显示请求负载"
},
"showTokenStats": {
"label": "显示 Token 总量"
},
"showSpeculative": {
"label": "显示投机解码"
}
},
"advanced": {
"promptSpeed": "Prompt 速度",
"avgSpeed": "平均生成速度",
"requests": "请求数",
"deferred": "排队中",
"slots": "Slot 占用 {processing}/{total}",
"speculative": "投机解码",
"avgPrompt": "平均 Prompt 速度",
"specDrafts": "投机草稿数",
"specAccepted": "投机接受 Token",
"specDraftTokens": "投机草稿 Token"
}
},
"wud": {
"name": "What's Up Docker",
"description": "显示有多少个已监控的容器有可用更新。",
"title": "What's Up Docker",
"monitored": "已监控",
"updatesAvailable": "{count, plural, one {# 个更新可用} other {# 个更新可用}}",
"updateAvailable": "有可用更新",
"openReleaseNotes": "打开发布说明",
"option": {
"showTitle": {
"label": "显示标题"
},
"showRing": {
"label": "显示更新进度环"
},
"layout": {
"label": "布局",
"option": {
"horizontal": {
"label": "水平"
},
"vertical": {
"label": "垂直"
}
}
},
"showUpdateList": {
"label": "显示容器更新列表",
"description": "列出每个有镜像更新可用的已监控容器"
}
}
}
},
"widgetPreview": {
@@ -4572,7 +4698,7 @@
"docker": "Docker",
"kubernetes": "Kubernetes",
"logs": "日志",
"api": "API",
"api": "API 与 MCP",
"certificates": "证书",
"tasks": "任务",
"backup": "备份"
@@ -4690,19 +4816,19 @@
"firstDayOfWeek": "一周的第一天",
"accessibility": "无障碍服务",
"onboardingTours": {
"title": "入门指南",
"description": "重置入门指南,以便在下次访问管理中心或面板时再次看到它们。",
"reset": "重置入门指南",
"title": "新手指南",
"description": "重置新手指南,以便您下次访问管理区域或面板时再次看到它们。",
"reset": "重置新手指南",
"resetSuccess": {
"title": "指南已重置",
"message": "入门指南将在您下次访问时再次显示"
"title": "新手指南已重置",
"message": "新手指南将在您下次访问时再次显示"
},
"status": {
"completed": "已完成",
"notStarted": "未开始"
},
"name": {
"management": "管理中心",
"management": "管理",
"dashboard": "面板"
}
}
@@ -4829,7 +4955,7 @@
"analytics": {
"title": "分析",
"general": {
"title": "发送匿名分析",
"title": "协助我们改进 Homarr",
"text": "这有助于我们了解用户实际使用哪些功能,从而决定将开发精力投入到哪里。我们会收集诸如您拥有多少小组件或集成、Homarr 版本以及使用的数据库类型等信息。仅此而已——不包含个人信息、URL、名称,也不含任何可能识别您本人或您个人配置的内容。"
}
},
@@ -5033,84 +5159,84 @@
"mcp": {
"label": "AI / MCP",
"title": "AI 集成",
"subtitle": "模型上下文协议 (MCP) 服务器",
"description": "将 AI 助手连接到您的 Homarr 实例,通过自然语言控制整个家庭实验室。在 Overseerr 上搜索和请求媒体,管理 Docker 容器,切换 Pi-hole,检查服务器健康状况,控制 Home Assistant 设备等——一切通过您的 AI 助手完成。",
"noApiKey": "您需要一个 API 密钥来与 MCP 服务器进行身份验证。",
"subtitle": "模型上下文协议(MCP)服务器",
"description": "将 AI 助手连接到您的 Homarr 实例,通过自然语言控制您的整个家庭实验室。在 Overseerr 上搜索和请求媒体资源,管理 Docker 容器,切换 Pi-hole,检查服务器健康状态,控制 Home Assistant 设备等——全部通过您的 AI 助手完成。",
"noApiKey": "您需要 API 密钥才能通过 MCP 服务器进行认证。",
"createApiKeyButton": "创建 API 密钥",
"endpoint": {
"title": "MCP 端点"
},
"authentication": {
"title": "认证",
"intro": "您可以使用 API 密钥或 OAuth 与 MCP 服务器进行身份验证。两种方法授予相同的访问级别。",
"intro": "您可以使用 API 密钥或 OAuth 对 MCP 服务器进行认证。两种方法授予的访问权限级别相同。",
"apiKey": {
"title": "API 密钥 (手动)",
"description": "在 MCP 客户端配置中传递带有完整 API 密钥的 ApiKey 标头。"
"title": "API 密钥(手动)",
"description": "在 MCP 客户端配置中通过 ApiKey 标头传递您的完整 API 密钥。"
},
"oauth": {
"title": "OAuth (浏览器登录)",
"description": "支持 OAuth 的客户端(如 Claude Code、opencode 或 Cursor)可以通过打开浏览器窗口进行身份验证。在客户端中运行 auth 命令,在浏览器中登录 Homarr,即可连接——API 密钥会自动创建。",
"hint": "OAuth 令牌在身份验证选项卡中显示为常规 API 密钥。您可以随时撤销它们。"
"title": "OAuth(浏览器登录)",
"description": "支持 OAuth 的客户端(如 Claude Code、opencode 或 Cursor)可通过打开浏览器窗口进行认证。在客户端中运行 auth 命令,在浏览器中登录 Homarr,即可完成连接——API 密钥将自动创建。",
"hint": "OAuth 令牌在“认证”标签页中显示为常规 API 密钥。您可以随时撤销它们。"
},
"permissions": "API 密钥决定了 AI 可用的权限——它与密钥所有者具有相同的访问级别。"
"permissions": "PI 密钥决定了 AI 可用的权限——它的访问级别与密钥所有者相同。"
},
"clientConfig": {
"title": "客户端配置",
"streamableHttp": {
"recommended": "推荐",
"title": "可流式 HTTP",
"worksWith": "适用于: Cursor, Claude Desktop, VS Code Copilot, Windsurf, Claude Code",
"addToConfig": "添加到您的 MCP 客户端配置文件:",
"cursor": "{name}: 项目或全局设置中的 {file}",
"title": "可流式传输的 HTTP",
"worksWith": "适用于:Cursor、Claude Desktop、VS Code Copilot、Windsurf、Claude Code",
"addToConfig": "添加到您的 MCP 客户端配置文件中:",
"cursor": "{name}:项目或全局设置中的 {file}",
"claudeDesktop": "{name}: {file}",
"claudeCode": "{name}: {file} 或使用 {command}",
"vsCodeCopilot": "{name}: 工作区设置中的 {file}",
"claudeCode": "{name}:{file} 或使用 {command}",
"vsCodeCopilot": "{name}:工作区设置中的 {file}",
"replaceHint": "将占位符替换为您在创建时收到的 API 密钥。"
},
"stdio": {
"title": "stdio (通过 mcp-remote 桥接的旧客户端)",
"description": "仅支持 stdio 传输的客户端。需要本地安装 {dependency} (Node.js):"
"title": "stdio(通过 mcp-remote 桥接的旧版客户端)",
"description": "适用于仅支持 stdio 传输的客户端。需要本地安装 {dependency}(Node.js):"
}
},
"testConnection": {
"title": "测试您的连接",
"description": "在终端中运行此命令以验证端点是否可达以及 API 密钥是否有效:",
"hint": "成功响应将返回 MCP 服务器功能。如果出现错误,请检查 JSON 响应以获取提示。"
"description": "在终端中运行以下命令,以验证端点可访问且您的 API 密钥有效:",
"hint": "成功响应将返回 MCP 服务器的能力信息。如果看到错误,请检查 JSON 响应中的提示。"
},
"availableTools": {
"title": "可用工具",
"description": "{count} 个工具可用。展开类别以查看各个工具及其描述。",
"description": "{count} 个工具可用。展开分类可查看各个工具及其描述。",
"columnType": "类型",
"columnName": "工具",
"columnDescription": "描述"
},
"troubleshooting": {
"title": "故障排除",
"title": "故障排查",
"missingHeader": {
"title": "\"缺少 ApiKey 标头\"",
"description": "您的客户端未发送 {header} 标头。验证 MCP 配置中的 {section} 部分是否正确。某些客户端在配置更改后需要重启。"
"title": "“缺少 ApiKey 标头”",
"description": "您的客户端未发送 {header} 标头。请检查 MCP 配置中的 {section} 部分是否正确。某些客户端在更改配置后需要重启。"
},
"invalidFormat": {
"title": "\"无效的 ApiKey 格式\"",
"description": "确保您复制了完整的 API 密钥,而不仅仅是部分。"
"title": "“ApiKey 格式无效”",
"description": "请确保您复制了完整的 API 密钥,而非仅复制了其中一部分。"
},
"invalidKey": {
"title": "\"无效的 API 密钥\"",
"description": "找不到密钥 ID 或令牌错误。API 密钥仅在创建时可见一次。如果您丢失了它,请删除旧密钥并创建新密钥。"
"title": "“API 密钥无效”",
"description": "未找到该密钥 ID,或令牌有误。API 密钥仅在创建时可见一次。如已丢失,请删除旧密钥并创建新密钥。"
},
"connectionRefused": {
"title": "连接被拒绝 / 超时",
"description": "确保 URL 可从您的 AI 客户端访问。如果 Homarr 在反向代理或 Docker 后运行,请使用外部 URL (不是 {localhost})。验证端口是否正确 (默认: Docker 为 7575)。"
"description": "请确保您的 AI 客户端能够访问该 URL。如果 Homarr 运行在反向代理后或 Docker 中,请使用外部 URL(而非 {localhost})。请确认端口是否正确(Docker 默认端口为 7575)。"
},
"toolsNotAppearing": {
"title": "工具未出现",
"description": "某些工具需要配置特定的集成(例如 Docker、Overseerr)。工具列表取决于 Homarr 实例中的可用内容。"
"title": "工具未显示",
"description": "部分工具需要先配置特定的集成(例如 Docker、Overseerr)。工具列表取决于您的 Homarr 实例中可用的功能。"
}
},
"copy": "复制",
"oneClick": {
"title": "一键安装",
"subtitle": "使用 OAuth — 首次使用时在浏览器中进行身份验证",
"subtitle": "使用 OAuth 认证——首次使用时在浏览器中进行身份验证",
"vsCode": "在 VS Code 中安装",
"cursor": "在 Cursor 中安装",
"copyUrl": "复制 MCP URL",
@@ -5123,16 +5249,16 @@
},
"backup": {
"title": "完整数据库备份",
"metaTitle": "备份与恢复",
"metaTitle": "备份 & 恢复",
"export": {
"title": "导出数据库",
"description": "下载包含所有设置、用户、面板和加密集成密钥的 SQLite 数据库的完整备份。备份打包为 ZIP 文件,附带加密密钥和元数据。",
"button": "下载备份",
"description": "下载 SQLite 数据库的完整备份,包含所有设置、用户、面板和加密的集成密钥。备份文件以 ZIP 格式打包,内含加密密钥和元数据。",
"button": "备份下载",
"error": "导出失败",
"confirm": {
"title": "敏感数据导出",
"warningTitle": "此导出包含您的整个数据库。",
"warningBody": "备份包括所有用户、集成密钥(加密)、API 密钥、面板配置和应用数据。将导出的文件安全存储,不要分享。",
"warningTitle": "此导出包含您的完整数据库。",
"warningBody": "备份包含所有用户、集成密钥(已加密)、API 密钥、面板配置和应用数据。请妥善保管导出的文件,切勿分享。",
"typePrompt": "输入 \"{phrase}\" 以继续",
"submit": "导出数据库",
"cancel": "取消"
@@ -5140,23 +5266,23 @@
},
"import": {
"title": "恢复数据库",
"description": "上传先前导出的备份 ZIP 以恢复您的数据库。这将替换您的整个数据库。如果备份是使用不同的加密密钥创建的,密钥将自动重新加密。",
"warning": "恢复备份将完全替换您当前的数据库并重启应用程序。此操作无法撤销。",
"description": "上传之前导出的备份 ZIP 文件以恢复数据库。此操作将替换整个数据库。如果备份使用的加密密钥不同,密钥将自动重新加密。",
"warning": "恢复备份将完全替换当前数据库并重启应用。此操作不可撤销。",
"dropzone": {
"title": "将备份 ZIP 拖到此处或点击浏览",
"description": "仅接受 Homarr SQLite 备份的 .zip 文件"
"title": "将备份 ZIP 文件拖放到此处,或点击浏览",
"description": "仅接受来自 Homarr SQLite 备份的 .zip 文件"
},
"changeFile": "更改文件",
"button": "恢复数据库",
"success": {
"title": "恢复完成",
"message": "数据库已成功恢复。应用程序正在重启……"
"message": "数据库已成功恢复。应用正在重启……"
},
"error": "导入失败"
},
"preview": {
"title": "备份内容",
"exported": "导出时间:{date}",
"exported": "导出于 {date}",
"loading": "正在分析备份文件……",
"errorTitle": "预览错误",
"entity": "实体",
@@ -5172,12 +5298,12 @@
"search_engine": "搜索引擎"
},
"analyzeError": "分析备份文件失败",
"invalidMissingMetadata": "无效备份:缺少 metadata.json",
"invalidMissingDb": "无效备份:缺少 db.sqlite"
"invalidMissingMetadata": "备份无效:缺少 metadata.json 文件",
"invalidMissingDb": "备份无效:缺少 db.sqlite 文件"
},
"restore": {
"dropzone": {
"title": "将备份 ZIP 拖到此处",
"title": "将备份 ZIP 文件拖放到此处",
"description": "接受 Homarr SQLite 备份的 .zip 文件(最大 {maxSize})"
},
"analyzing": "正在分析备份……",
@@ -5187,7 +5313,7 @@
"analysisError": "分析错误",
"preview": {
"title": "备份内容",
"exported": "导出时间:{date}",
"exported": "导出于 {date}",
"entity": "实体",
"count": "数量",
"boards": "面板",
@@ -5200,43 +5326,43 @@
"media": "媒体",
"search_engine": "搜索引擎"
},
"migrationsApplied": "已应用 {count} 个迁移",
"migrationsAppliedPlural": "已应用 {count} 个迁移",
"migrationsOlderVersion": "此备份来自较旧的 Homarr 版本。已应用 {count} 个架构迁移以使其更新。",
"migrationsOlderVersionPlural": "此备份来自较旧的 Homarr 版本。已应用 {count} 个架构迁移以使其更新。",
"migrationsUpToDate": "数据库架构已是最新(已应用 {count} 个迁移)",
"upToDate": "已更新",
"migrationsPending": "{count} 个待处理"
"migrationsApplied": "已应用 {count} 项迁移",
"migrationsAppliedPlural": "已应用 {count} 项迁移",
"migrationsOlderVersion": "此备份来自较旧版本的 Homarr。已应用 {count} 项数据库迁移以将其更新至最新版本。",
"migrationsOlderVersionPlural": "此备份来自较旧版本的 Homarr。已应用 {count} 项数据库迁移以将其更新至最新版本。",
"migrationsUpToDate": "数据库架构已为最新(已应用 {count} 项迁移)",
"upToDate": "已是最新",
"migrationsPending": "{count} 项待处理"
},
"migration": {
"title": "正在应用迁移 ({current}/{total})",
"title": "正在应用迁移({current}/{total})",
"migrationLabel": "迁移 {index}"
},
"confirm": {
"title": "破坏性操作",
"warningTitle": "这将永久替换您的整个数据库。",
"warningBody": "所有当前面板、用户、应用、集成、设置和会话都将丢失。此操作无法撤销。应用将在恢复后重启。",
"warningBody": "所有现有面板、用户、应用、集成、设置和会话都将丢失。此操作不可撤销。恢复完成后应用将重启。",
"typePrompt": "输入 \"{phrase}\" 以继续",
"cancel": "取消",
"submit": "恢复数据库"
},
"progress": {
"title": "正在恢复数据库……",
"extracting": "提取备份存档",
"migrating": "应用数据库迁移",
"encrypting": "重新加密集成密钥",
"swapping": "替换数据库文件",
"restarting": "重启服务器"
"extracting": "正在解压备份压缩包",
"migrating": "正在应用数据库迁移",
"encrypting": "正在重新加密集成密钥",
"swapping": "正在替换数据库文件",
"restarting": "正在重启服务器"
},
"complete": {
"title": "恢复完成",
"message": "数据库已成功恢复。服务器正在重启——请几秒钟后刷新页面。"
"message": "数据库已成功恢复。服务器正在重启——请稍后刷新页面。"
},
"failed": {
"title": "恢复失败"
},
"invalidMissingMetadata": "无效备份:缺少 metadata.json",
"invalidMissingDb": "无效备份:缺少 db.sqlite",
"invalidMissingMetadata": "备份无效:缺少 metadata.json 文件",
"invalidMissingDb": "备份无效:缺少 db.sqlite 文件",
"analyzeError": "分析备份失败"
}
}
@@ -5918,14 +6044,14 @@
"title": "偏好设置",
"children": {
"detail": {
"title": "选择要更改的偏好",
"backHint": "按 Backspace 或使用返回按钮返回",
"title": "选择要更改的偏好设置",
"backHint": "按 Backspace 键或使用返回按钮返回",
"backAction": "返回"
}
},
"notification": {
"error": {
"message": "无法更新偏好"
"message": "无法更新偏好设置"
}
},
"option": {
@@ -5938,11 +6064,11 @@
},
"board": {
"none": "无",
"unavailable": "当前选择(不可用)"
"unavailable": "当前已选(不可用)"
},
"searchEngine": {
"none": "无",
"unavailable": "当前选择(不可用)"
"unavailable": "当前已选(不可用)"
},
"colorScheme": {
"label": "配色方案",
@@ -5977,42 +6103,42 @@
}
},
"ddgBangs": {
"label": "启用 DuckDuckGo bangs",
"label": "启用 DuckDuckGo Bang 快捷搜索",
"children": {
"detail": {
"title": "启用 DuckDuckGo bangs"
"title": "启用 DuckDuckGo Bang 快捷搜索"
}
}
},
"firstDayOfWeek": {
"label": "一周的第一天",
"label": "每周的第一天",
"children": {
"detail": {
"title": "选择一周的第一天"
"title": "选择每周的第一天"
}
}
},
"homeBoardId": {
"label": "主面板",
"label": "默认面板",
"children": {
"detail": {
"title": "选择您的主面板"
"title": "选择您的默认面板"
}
}
},
"mobileHomeBoardId": {
"label": "移动端主面板",
"label": "移动端默认面板",
"children": {
"detail": {
"title": "选择您的移动端主面板"
"title": "选择您的移动端默认面板"
}
}
},
"pingIconsEnabled": {
"label": "为 ping 使用图标",
"label": "使用图标表示 ping 状态",
"children": {
"detail": {
"title": "为 ping 使用图标"
"title": "使用图标表示 ping 状态"
}
}
},
@@ -6032,16 +6158,16 @@
}
},
"media": {
"help": "搜索电影或节目以请求",
"help": "搜索要请求的电影或剧集",
"requestMovie": "请求电影",
"requestSeries": "请求电视节目",
"openIn": "在 {kind} 中打开",
"group": {
"title": "电影和节目"
"title": "电影和剧集"
},
"action": {
"search": {
"label": "搜索电影或节目",
"label": "搜索电影或剧集",
"description": "开始输入以搜索您的媒体请求集成",
"disabled": {
"noIntegration": "添加 Overseerr、Jellyseerr 或 Seerr 集成以启用媒体搜索。",
@@ -6456,39 +6582,39 @@
"manage": {
"welcome": {
"title": "欢迎使用 Homarr!",
"description": "这是您的控制中心。您可以在此管理面板、应用、集成、用户和服务器设置。让我们浏览每个部分。"
"description": "这是您的控制中心。您可以在这里管理面板、应用、集成、用户和服务器设置。让我们逐一了解每个部分。"
},
"boardsList": {
"title": "您的面板",
"description": "这里是您的所有面板。每个面板都有自己的布局、背景和访问设置。您可以查看每个面板的创建者以及它是公开的还是私有的。"
"description": "这里是您的所有面板。每个面板都有自己的布局、背景和访问设置。您可以查看每个面板的创建者以及它是公开还是私有的。"
},
"boardsCreate": {
"title": "创建面板",
"description": "点击此处创建新面板或从以前的 Homarr 安装中导入。每个面板都有自己可共享的 URL。"
"description": "点击此处创建新面板,或从之前的 Homarr 安装中导入。每个面板都有独立的可分享链接。"
},
"appsList": {
"title": "您的应用",
"description": "这些是您添加的所有应用——指向自托管服务的链接。每个应用都有图标、可选的健康检查和面板上的状态徽章。"
"description": "这些是您已添加的所有应用——指向您自托管服务的链接。每个应用都配有图标、可选的健康检查,并在面板上显示状态标记。"
},
"appsCreate": {
"title": "添加应用",
"description": "在此注册新服务。为其命名、URL 和图标——它将作为磁贴显示在您的面板上,并带有实时状态监控。"
"description": "在此处注册新服务。设置名称、URL 和图标后,它将以磁贴形式出现在您的面板上,并带有实时状态监控。"
},
"integrationsList": {
"title": "您的集成",
"description": "这些是连接到 Homarr 的服务——Sonarr、Radarr、Plex、Jellyfin 及 50 多种其他服务。所有凭据在静态时均加密。"
"description": "这些是已连接到 Homarr 的服务——Sonarr、Radarr、Plex、Jellyfin 以及 50 多种其他服务。所有凭证均以加密方式存储。"
},
"integrationsCreate": {
"title": "添加集成",
"description": "在此连接新服务。选择类型,输入 URL 和 API 密钥——Homarr 在保存前会验证连接。"
"description": "在此处连接新服务。选择类型,输入 URL 和 API 密钥——Homarr 会在保存前验证连接。"
},
"usersList": {
"title": "您的用户",
"description": "查看所有有访问权限的人员。将用户组织到组中,并控制谁可以查看或编辑每个面板。"
"description": "查看所有拥有访问权限的用户。将用户归入不同组别,并控制谁可以查看或编辑每个面板。"
},
"usersCreate": {
"title": "创建用户",
"description": "在此添加新用户账户。您也可以使用邀请链接方便地引入。导览到此结束——您已准备就绪!"
"description": "在此处添加新用户账号。您也可以使用邀请链接简化入门流程。导览已结束——您已准备就绪!"
}
},
"board": {
@@ -6502,15 +6628,15 @@
},
"settings": {
"title": "面板设置",
"description": "更改此面板的名称、列数、背景、颜色以及访问权限。每个面板可以是公开或私有的。"
"description": "更改此面板的名称、列数、背景、颜色以及访问权限。每个面板均可设为公开或私有。"
},
"switcher": {
"title": "切换面板",
"description": "在面板间跳转。为不同目的创建独立的面板——媒体、网络、按用户或任何您喜欢的。"
"description": "在不同面板之间跳转。您可以为不同用途创建独立的面板——媒体、网络、按用户划分,或任何您喜欢的分类。"
},
"userMenu": {
"title": "您的个人资料",
"description": "更改您的配色方案、语言、主面板和偏好。您也可以从此处跳转到管理中心。"
"description": "更改您的配色方案、语言、默认面板和偏好设置。您也可以从这里跳转到管理区域。"
}
}
}
+49 -3
View File
@@ -1204,6 +1204,10 @@
"bazarr": {
"name": "crwdns5799:0crwdne5799:0",
"description": "crwdns5801:0crwdne5801:0"
},
"wud": {
"name": "crwdns6193:0crwdne6193:0",
"description": "crwdns6195:0crwdne6195:0"
}
},
"media": {
@@ -1286,10 +1290,20 @@
"here": "crwdns552:0crwdne552:0",
"iconPicker": {
"label": "crwdns554:0crwdne554:0",
"header": "crwdns556:0{countIcons}crwdne556:0",
"headerLoading": "crwdns5681:0crwdne5681:0",
"header": "crwdns6227:0{countIcons}crwdne6227:0",
"headerLoading": "crwdns6229:0crwdne6229:0",
"noResults": "crwdns5683:0{search}crwdne5683:0",
"error": "crwdns5685:0crwdne5685:0"
"error": "crwdns5685:0crwdne5685:0",
"description": "crwdns6225:0crwdne6225:0",
"clear": "crwdns6231:0crwdne6231:0",
"uploadImage": "crwdns6233:0crwdne6233:0",
"results": "crwdns6235:0crwdne6235:0",
"localImages": "crwdns6237:0crwdne6237:0",
"svgIcons": "crwdns6239:0crwdne6239:0",
"otherImages": "crwdns6241:0crwdne6241:0",
"urlLoading": "crwdns6243:0crwdne6243:0",
"urlReady": "crwdns6245:0crwdne6245:0",
"urlError": "crwdns6247:0crwdne6247:0"
},
"colorScheme": {
"options": {
@@ -4015,6 +4029,38 @@
"subtitle": "crwdns4975:0crwdne4975:0"
}
}
},
"wud": {
"name": "crwdns6197:0crwdne6197:0",
"description": "crwdns6199:0crwdne6199:0",
"title": "crwdns6201:0crwdne6201:0",
"monitored": "crwdns6203:0crwdne6203:0",
"updatesAvailable": "crwdns6205:0count={count}crwdne6205:0",
"updateAvailable": "crwdns6207:0crwdne6207:0",
"openReleaseNotes": "crwdns6209:0crwdne6209:0",
"option": {
"showTitle": {
"label": "crwdns6211:0crwdne6211:0"
},
"showRing": {
"label": "crwdns6213:0crwdne6213:0"
},
"layout": {
"label": "crwdns6215:0crwdne6215:0",
"option": {
"horizontal": {
"label": "crwdns6217:0crwdne6217:0"
},
"vertical": {
"label": "crwdns6219:0crwdne6219:0"
}
}
},
"showUpdateList": {
"label": "crwdns6221:0crwdne6221:0",
"description": "crwdns6223:0crwdne6223:0"
}
}
}
},
"widgetPreview": {
File diff suppressed because it is too large. Load diff
+50 -4
View File
@@ -1150,6 +1150,10 @@
"bazarr": {
"name": "",
"description": ""
},
"wud": {
"name": "Hvad Er Opad Docker",
"description": "Overvåg hvilke af dine Docker-containere der er tilgængelige billedopdateringer"
}
},
"media": {
@@ -1232,10 +1236,20 @@
"here": "her",
"iconPicker": {
"label": "Ikon-URL",
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig.",
"headerLoading": "",
"header": "Søg efter {countIcons} billeder efter navn",
"headerLoading": "Søg billeder efter navn",
"noResults": "",
"error": ""
"error": "",
"description": "Søg i biblioteket, indsæt en billed-URL, eller upload en fil.",
"clear": "Ryd billede",
"uploadImage": "Upload billede",
"results": "Billede søgeresultater",
"localImages": "Dine billeder",
"svgIcons": "SVG ikoner",
"otherImages": "Andre billeder",
"urlLoading": "Kontrollerer billede…",
"urlReady": "Billedet er klar til brug",
"urlError": "Dette billede kunne ikke indlæses. Tjek URL'en og prøv igen."
},
"colorScheme": {
"options": {
@@ -2842,7 +2856,7 @@
"option": {
"albumId": {
"label": "Album ID",
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
},
"rotationIntervalSeconds": {
"label": "Interval i sekunder",
@@ -3946,6 +3960,38 @@
"subtitle": "Netværkstrafik af Docker-containere"
}
}
},
"wud": {
"name": "Hvad Er Opad Docker",
"description": "Vis hvor mange overvågede containere der er tilgængelige opdateringer.",
"title": "Hvad Er Opad Docker",
"monitored": "Overvåget",
"updatesAvailable": "{count, plural, one {# opdatering tilgængelig} other {# opdateringer tilgængelige}}",
"updateAvailable": "Opdatering tilgængelig",
"openReleaseNotes": "Åbn udgivelsesnoter",
"option": {
"showTitle": {
"label": "Vis titel"
},
"showRing": {
"label": "Vis opdateringsring"
},
"layout": {
"label": "Layout",
"option": {
"horizontal": {
"label": "Horisontal"
},
"vertical": {
"label": "Vertikal"
}
}
},
"showUpdateList": {
"label": "Vis liste over container-opdatering",
"description": "Lister hver overvåget beholder, der har en billedopdatering tilgængelig"
}
}
}
},
"widgetPreview": {
File diff suppressed because it is too large. Load diff
+121 -75
View File
@@ -1204,6 +1204,10 @@
"bazarr": {
"name": "Bazarr",
"description": "Schwesteranwendung zu Sonarr und Radarr, welche Untertitel und Downloads verwaltet"
},
"wud": {
"name": "Was ist Docker hoch",
"description": "Überwachen Sie, welche Docker-Container Bildupdates verfügbar sind"
}
},
"media": {
@@ -1286,10 +1290,20 @@
"here": "Hier",
"iconPicker": {
"label": "Icon URL",
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.",
"headerLoading": "Tippe um die Symbole zu durchsuchen…",
"header": "Suche {countIcons} Bilder nach Namen",
"headerLoading": "Bilder nach Namen suchen",
"noResults": "Keine Symbole stimmen mit \"{search}\" überein.",
"error": "Konnte Symbole nicht laden."
"error": "Konnte Symbole nicht laden.",
"description": "Suchen Sie die Bibliothek, fügen Sie eine Bild-URL ein oder laden Sie eine Datei hoch.",
"clear": "Bild löschen",
"uploadImage": "Bild hochladen",
"results": "Bildsuchergebnisse",
"localImages": "Ihre Bilder",
"svgIcons": "SVG-Symbole",
"otherImages": "Andere Bilder",
"urlLoading": "Bild wird überprüft…",
"urlReady": "Bild ist bereit zur Verwendung",
"urlError": "Dieses Bild konnte nicht geladen werden. Überprüfen Sie die URL und versuchen Sie es erneut."
},
"colorScheme": {
"options": {
@@ -2178,7 +2192,7 @@
},
"visibleStorageVolumes": {
"label": "Sichtbare Speichervolumes",
"description": "Nur verfügbar, wenn alle Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes zu zeigen. ",
"description": "Nur verfügbar, wenn alle ausgewählten Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes anzuzeigen.",
"placeholder": "Speichervolumen auswählen"
},
"defaultTab": {
@@ -2288,7 +2302,7 @@
"error": {
"internalServerError": "Fehler beim Abrufen der Container Statistiken",
"layoutSaveTitle": "Tabellenlayout nicht gespeichert",
"layoutSaveMessage": "Die Docker-Tabelle konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden. "
"layoutSaveMessage": "Das Docker-Tabellenlayout konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden."
},
"empty": {
"noContainers": "Keine Docker-Container gefunden"
@@ -2588,10 +2602,10 @@
},
"showBitrate": {
"label": "Zeigt die Bitrate an",
"description": ""
"description": "Zeige die aktuelle Wiedergabe-Bitrate neben dem Ort an"
},
"showLocation": {
"label": "",
"label": "Standort anzeigen",
"description": "Zeigt an, ob der Stream über das LAN oder WAN läuft"
}
},
@@ -2607,8 +2621,8 @@
"status": "Status",
"directPlay": "Direkte Wiedergabe",
"directStream": "Direkter Stream",
"transcodeVideo": "",
"transcodeAudio": "",
"transcodeVideo": "Video wird transkodiert",
"transcodeAudio": "Audio wird transkodiert",
"transcoding": "Transkodierung",
"paused": "Pausiert",
"remaining": "{minutes} Min. verbleibend",
@@ -2815,8 +2829,8 @@
}
},
"showOsVersion": {
"label": "",
"description": ""
"label": "Betriebssystem-Version anzeigen",
"description": "Füge die Betriebssystemversion jedem Verteilungsetikett hinzu, sofern verfügbar"
},
"showComplianceHero": {
"label": "",
@@ -2896,7 +2910,7 @@
"option": {
"albumId": {
"label": "Album ID",
"description": "Dies ist eine eindeutige ID, die sich auf Ihr Immich Album bezieht. Sie können diese ID finden, indem Sie ein Album öffnen und die einzigartige Zeichenkette von der URL kopieren, zum Beispiel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
"description": "Dies ist eine eindeutige ID, die sich auf Ihr Immich Album bezieht. Sie können diese ID finden, indem Sie ein Album öffnen und die einzigartige Zeichenkette von der URL kopieren, zum Beispiel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
},
"rotationIntervalSeconds": {
"label": "Intervall in Sekunden",
@@ -2966,8 +2980,8 @@
"noColumns": "Spalten in Elementen auswählen",
"noCommunications": "Kann keine Daten von der Integration laden",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
"actionFailed": "Aktion fehlgeschlagen",
"actionFailedMessage": "Diese Aktion konnte nicht abgeschlossen werden. Bitte versuche es erneut."
},
"items": {
"actions": {
@@ -3255,7 +3269,7 @@
"queued": ""
},
"type": {
"movie": "",
"movie": "Film",
"episode": ""
},
"empty": {
@@ -3615,7 +3629,7 @@
},
"resource": {
"routers": "",
"services": "",
"services": "Dienste",
"middlewares": ""
},
"status": {
@@ -3896,16 +3910,16 @@
"temp": "",
"battery": "",
"services": "Dienste",
"uptime": "",
"uptime": "Verfügbarkeit",
"agent": "Agent"
}
},
"beszelAlerts": {
"name": "",
"description": "",
"name": "Beszel-Meldungen",
"description": "Zeige Beszels Benachrichtigungseinstellungen und -verlauf",
"option": {
"showHistory": {
"label": ""
"label": "Alarmverlauf anzeigen"
},
"maxHistoryItems": {
"label": ""
@@ -3916,9 +3930,9 @@
"internalServerError": ""
},
"history": "Verlauf",
"resolved": "",
"resolved": "Gelöst",
"status": {
"triggered": "",
"triggered": "Ausgelöst",
"ok": "Ok"
}
},
@@ -3927,43 +3941,43 @@
"description": "",
"option": {
"systemId": {
"label": "",
"label": "System",
"description": ""
},
"timePeriod": {
"label": ""
"label": "Zeitraum"
},
"showCpu": {
"label": ""
"label": "CPU-Diagramm anzeigen"
},
"showMemory": {
"label": ""
"label": "RAM-Diagramm anzeigen"
},
"showDisk": {
"label": ""
"label": "Festplatten-Diagramm anzeigen"
},
"showDiskIO": {
"label": ""
"label": "Festplatten-E/A-Diagramm anzeigen"
},
"showNetwork": {
"label": ""
"label": "Netzwerk-Diagramm anzeigen"
},
"showDockerCpu": {
"label": ""
"label": "Docker-CPU-Diagramm anzeigen"
},
"showDockerMemory": {
"label": ""
"label": "Docker-RAM-Diagramm anzeigen"
},
"showDockerNetwork": {
"label": ""
"label": "Docker-Netzwerk-Diagramm anzeigen"
}
},
"selectSystem": "System auswählen",
"empty": {
"noSystems": ""
"noSystems": "Keine Systeme gefunden"
},
"error": {
"internalServerError": "",
"internalServerError": "Keine Verbindung zu Beszel möglich. Überprüfe deine Integrations-Einstellungen.",
"liveConnectionFailed": "",
"switchToHistorical": "",
"systemNotFound": ""
@@ -3971,48 +3985,80 @@
"chart": {
"cpu": {
"title": "CPU Auslastung",
"subtitle": "",
"subtitle": "Durchschnittliche Systemweite CPU-Auslastung",
"series": "CPU"
},
"memory": {
"title": "",
"subtitle": "",
"title": "Speicherverbrauch",
"subtitle": "Genaue Auslastung zum erfassten Zeitpunkt",
"series": "Belegt",
"cache": ""
},
"disk": {
"title": "Festplattenbelegung",
"subtitle": "",
"series": ""
"series": "Root"
},
"diskIO": {
"title": "",
"subtitle": "",
"read": "",
"write": ""
"title": "Festplatten-E/A",
"subtitle": "Durchsatz des Root-Dateisystems",
"read": "Lesen",
"write": "Schreiben"
},
"storage": {
"title": "",
"subtitle": "",
"series": ""
"title": "Speicher",
"subtitle": "Festplattenverbrauch pro Dateisystem",
"series": "Belegt"
},
"network": {
"title": "",
"subtitle": "",
"sent": "",
"recv": ""
"title": "Bandbreite",
"subtitle": "Netzwerkverkehr öffentlicher Schnittstellen",
"sent": "Gesendet",
"recv": "Empfangen"
},
"dockerCpu": {
"title": "",
"subtitle": ""
"title": "Docker-CPU-Nutzung",
"subtitle": "Durchschnittliche CPU-Nutzung der Container"
},
"dockerMemory": {
"title": "",
"subtitle": ""
"title": "Docker-RAM-Nutzung",
"subtitle": "RAM-Nutzung der Docker-Container"
},
"dockerNetwork": {
"title": "",
"subtitle": ""
"title": "Docker-Netzwerk-E/A",
"subtitle": "Netzwerkverkehr der Docker-Container"
}
}
},
"wud": {
"name": "Was ist Docker hoch",
"description": "Zeigt an, wie viele überwachte Container über Updates verfügen.",
"title": "Was ist Docker hoch",
"monitored": "Überwacht",
"updatesAvailable": "{count, plural, one {# Update verfügbar} other {# Updates verfügbar}}",
"updateAvailable": "Update verfügbar",
"openReleaseNotes": "Versionshinweise öffnen",
"option": {
"showTitle": {
"label": "Titel anzeigen"
},
"showRing": {
"label": "Update-Ring anzeigen"
},
"layout": {
"label": "Ansicht",
"option": {
"horizontal": {
"label": "Horizontal"
},
"vertical": {
"label": "Vertikal"
}
}
},
"showUpdateList": {
"label": "Container-Update-Liste anzeigen",
"description": "Listet jeden überwachten Container auf, der eine Bildaktualisierung zur Verfügung hat"
}
}
}
@@ -4389,7 +4435,7 @@
"text": "Konnte die angeforderte Ressource nicht finden"
},
"error": {
"title": "",
"title": "Diese Seite konnte nicht geladen werden",
"text": "",
"digest": ""
},
@@ -4954,7 +5000,7 @@
"copy": "",
"oneClick": {
"title": "",
"subtitle": "",
"subtitle": "OAuth wird verwendet. Authentifiziere dich bei der ersten Nutzung im Browser",
"vsCode": "In VS Code installieren",
"cursor": "In Cursor installieren",
"copyUrl": "MCP URL kopieren",
@@ -4974,7 +5020,7 @@
"button": "Sicherung herunterladen",
"error": "Export fehlgeschlagen",
"confirm": {
"title": "",
"title": "Export sensibler Daten",
"warningTitle": "Dieser Export enthält Ihre gesamte Datenbank.",
"warningBody": "Das Backup umfasst alle Benutzer, Integrationsgeheimnisse (verschlüsselt), API-Keys, Board-Konfigurationen und App-Daten. Speichern Sie die exportierte Datei sicher ab und teilen Sie diese nicht.",
"typePrompt": "Geben Sie \"{phrase}\" ein, um fortzufahren",
@@ -4984,8 +5030,8 @@
},
"import": {
"title": "Datenbank wiederherstellen",
"description": "",
"warning": "",
"description": "Lade ein zuvor exportiertes Backup-ZIP‑File hoch, um deine Datenbank wiederherzustellen. Damit ersetzt du deine gesamte jetzige Datenbank. Falls das Backup mit einem anderen Verschlüsselungs-Key erstellt wurde, werden vertrauliche Daten (Secrets) automatisch neu verschlüsselt.",
"warning": "Ein Backup wiederherzustellen, wird deine jetzige Datenbank komplett ersetzen und das Programm neu starten. Diese Aktion kann nicht rückgängig gemacht werden.",
"dropzone": {
"title": "Ziehen Sie Ihre Backup-ZIP hierher oder klicken Sie zum Durchsuchen",
"description": "Nur .zip Dateien aus einer Homarr SQLite-Sicherung werden akzeptiert"
@@ -5894,7 +5940,7 @@
}
},
"availability": {
"available": "",
"available": "Verfügbar",
"partiallyAvailable": "",
"processing": "",
"requested": "",
@@ -6176,10 +6222,10 @@
"header": {
"season": "Staffel",
"episodes": "Episoden",
"status": ""
"status": "Status"
},
"status": {
"requested": ""
"requested": "Angefragt"
}
},
"button": {
@@ -6292,18 +6338,18 @@
}
},
"onboardingTour": {
"learnMore": "",
"next": "",
"prev": "",
"done": "",
"skip": "",
"learnMore": "Lese die Dokumentation",
"next": "Weiter",
"prev": "Zurück",
"done": "Fertig",
"skip": "Überspringe die Einführung",
"manage": {
"welcome": {
"title": "",
"description": ""
"title": "Willkommen bei Homarr!",
"description": "Das ist dein Kontrollzentrum. Von hier steuerst du Boards, Apps, Integrationen, Benutzer und die Servereinstellungen. Lass uns eine Einführung in jeden Bereich machen."
},
"boardsList": {
"title": "",
"title": "Deine Boards",
"description": ""
},
"boardsCreate": {
@@ -6337,15 +6383,15 @@
},
"board": {
"welcome": {
"title": "",
"description": ""
"title": "Das ist dein Board",
"description": "Ein Drag-and-drop-Dashboard, wo du Apps und Widgets anordnen kannst. Du kannst jederzeit Strg+K drücken, um nach Apps, Boards und Aktionen zu suchen."
},
"editMode": {
"title": "Bearbeitungsmodus",
"description": ""
"description": "Drücke hier, um in den Bearbeitungsmodus zu kommen. Dort kannst du Widgets, Apps und Abschnitte hinzufügen, welche du durch anschließendes Ziehen neu anordnen und an den Ecken in der Größe anpassen kannst."
},
"settings": {
"title": "",
"title": "Board-Einstellungen",
"description": ""
},
"switcher": {
File diff suppressed because it is too large. Load diff
+112 -66
View File
@@ -91,7 +91,7 @@
},
"settings": {
"title": "Settings",
"subtitle": "Configure server settings"
"subtitle": "Configure server settings."
},
"integrations": {
"title": "Integrations",
@@ -351,7 +351,7 @@
"delete": {
"label": "Permanently delete user",
"description": "This deletes this user, including their preferences. This will not delete any boards. The user will not be notified.",
"confirm": "Are you sure that you want to delete the user {username}, as well as their preferences"
"confirm": "Are you sure that you want to delete the user {username}, as well as their preferences?"
},
"select": {
"label": "Select a user",
@@ -517,7 +517,7 @@
"transfer": {
"label": "Transfer group ownership",
"description": "Transfer ownership of this group to another user.",
"confirm": "Are you want to transfer the ownership of the group, {name} to {username}",
"confirm": "Are you want to transfer the ownership of the group, {name} to {username}?",
"notification": {
"success": {
"message": "Successfully transferred group {group} to {user}"
@@ -536,7 +536,7 @@
},
"delete": {
"label": "Delete group",
"description": "Once you delete this group, you can not revert the change. Please be careful",
"description": "Once you delete this group, you can not revert the change. Please be careful.",
"confirm": "Are you sure you want to delete the group, {name}?",
"notification": {
"success": {
@@ -1175,6 +1175,10 @@
"bazarr": {
"name": "Bazarr",
"description": "Companion application to Sonarr and Radarr that manages and downloads subtitles"
},
"wud": {
"name": "What's Up Docker",
"description": "Monitor which of your Docker containers have image updates available"
}
},
"media": {
@@ -1257,10 +1261,20 @@
"here": "here",
"iconPicker": {
"label": "Icon URL",
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one.",
"headerLoading": "Type to search icons…",
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one",
"headerLoading": "Type to search icons",
"noResults": "No icons match \"{search}\".",
"error": "Couldn't load icons."
"error": "Couldn't load icons.",
"description": "Search the library, paste an image URL, or upload a file.",
"clear": "Clear image",
"uploadImage": "Upload image",
"results": "Image search results",
"localImages": "Your images",
"svgIcons": "SVG icons",
"otherImages": "Other images",
"urlLoading": "Checking image…",
"urlReady": "Image is ready to use",
"urlError": "This image could not be loaded. Check the URL and try again."
},
"colorScheme": {
"options": {
@@ -1318,7 +1332,7 @@
"login": "Log-in",
"homeBoard": "Your home board",
"loggedOut": "Logged out",
"updateAvailable": "New update available: {tag}"
"updateAvailable": "{countUpdates} updates available: {tag}"
}
},
"dangerZone": "Danger zone",
@@ -1705,7 +1719,7 @@
"label": "Display seconds"
},
"useCustomTimezone": {
"label": " "
"label": "Use a fixed timezone"
},
"timezone": {
"label": "Time zone",
@@ -2250,19 +2264,19 @@
"label": "Invert sorting"
},
"columnOrder": {
"label": ""
"label": "Column order"
},
"columnWidths": {
"label": ""
"label": "Column widths"
}
},
"error": {
"internalServerError": "Failed to fetch containers stats",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
"layoutSaveTitle": "Table layout not saved",
"layoutSaveMessage": "The Docker table layout could not be saved. Try moving or resizing the column again."
},
"empty": {
"noContainers": ""
"noContainers": "No containers found"
}
},
"coolify": {
@@ -2558,17 +2572,17 @@
"description": "Disabling this will not work for Plex"
},
"showBitrate": {
"label": "",
"description": ""
"label": "Show bitrate",
"description": "Show the current playback bitrate next to the location"
},
"showLocation": {
"label": "",
"description": ""
"label": "Show location",
"description": "Show whether the stream is on your LAN or WAN"
}
},
"footer": {
"streams": "{count, plural, one {# active stream} other {# active streams}}",
"totalBitrate": ""
"totalBitrate": "Total: {bitrate}"
},
"items": {
"currentlyPlaying": "Currently playing",
@@ -2577,9 +2591,9 @@
"id": "Id",
"status": "Status",
"directPlay": "Direct play",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"directStream": "Direct stream",
"transcodeVideo": "Transcoding video",
"transcodeAudio": "Transcoding audio",
"transcoding": "Transcoding",
"paused": "Paused",
"remaining": "{minutes} min left",
@@ -2680,7 +2694,7 @@
"option": {
"albumId": {
"label": "Album ID",
"description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
"description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
},
"rotationIntervalSeconds": {
"label": "Interval in seconds",
@@ -2740,18 +2754,18 @@
"description": ""
},
"columnOrder": {
"label": ""
"label": "Column order"
},
"columnWidths": {
"label": ""
"label": "Column widths"
}
},
"errors": {
"noColumns": "",
"noCommunications": "",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
"noItems": "No downloads found",
"actionFailed": "Action failed",
"actionFailedMessage": "The action could not be completed. Please try again."
},
"items": {
"actions": {
@@ -2766,7 +2780,7 @@
"detailsTitle": ""
},
"downSpeed": {
"columnTitle": "",
"columnTitle": "Down",
"detailsTitle": ""
},
"index": {
@@ -2775,16 +2789,16 @@
},
"id": {
"columnTitle": "",
"detailsTitle": ""
"detailsTitle": "Item ID"
},
"integration": {
"columnTitle": ""
},
"name": {
"columnTitle": ""
"columnTitle": "Job name"
},
"progress": {
"columnTitle": "",
"columnTitle": "Progress",
"detailsTitle": ""
},
"ratio": {
@@ -2804,13 +2818,13 @@
"detailsTitle": ""
},
"state": {
"columnTitle": "",
"columnTitle": "State",
"detailsTitle": ""
},
"time": {
"columnTitle": "",
"detailsTitle": "",
"eta": ""
"eta": "ETA"
},
"type": {
"columnTitle": "",
@@ -2856,21 +2870,21 @@
},
"globalRatio": "",
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
"copyName": "Copy name",
"copyId": "Copy ID",
"clearFilters": "Clear filters"
},
"footer": {
"filter": ""
"filter": "Filter downloads"
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
"show": "Show global stats",
"hide": "Hide global stats",
"overallProgress": "Overall queue progress",
"totalSize": "Total queue size",
"clients": "{count, plural, one {# client} other {# clients}}"
},
"disconnected": ""
"disconnected": "(disconnected)"
},
"mediaReleases": {
"name": "",
@@ -2902,7 +2916,7 @@
}
},
"mediaRequests-requestList": {
"name": "",
"name": "Media Requests List",
"description": "",
"option": {
"linksTargetNewTab": {
@@ -2924,15 +2938,15 @@
"unknown": "",
"pending": "",
"processing": "",
"requested": "",
"requested": "Requested",
"partiallyAvailable": "",
"available": "",
"blacklisted": "",
"blacklisted": "Blacklisted",
"deleted": ""
},
"status": {
"pending": "",
"approved": "",
"approved": "Approved",
"declined": "",
"failed": "",
"completed": ""
@@ -2945,7 +2959,7 @@
"option": {},
"titles": {
"stats": {
"main": "",
"main": "Media Stats",
"approved": "",
"pending": "",
"processing": "",
@@ -2989,15 +3003,15 @@
"panel": {
"statistics": {
"empty": "",
"transcodes": "",
"transcodes": "Transcodes",
"transcodesCount": "",
"healthChecksCount": "",
"filesCount": "",
"savedSpace": "",
"healthChecks": "",
"videoCodecs": "",
"videoContainers": "",
"videoResolutions": ""
"healthChecks": "Health checks",
"videoCodecs": "Codecs",
"videoContainers": "Containers",
"videoResolutions": "Resolutions"
},
"workers": {
"empty": "",
@@ -3178,7 +3192,7 @@
"option": {},
"card": {
"vpn": {
"countConnected": ""
"countConnected": "{count} connected"
}
},
"error": {
@@ -3195,14 +3209,14 @@
"name": ""
},
"wifi": {
"name": ""
"name": "Wi-Fi"
}
},
"users": {
"label": ""
},
"guests": {
"label": ""
"label": "Guests"
}
},
"option": {
@@ -3414,7 +3428,7 @@
"option": {
"cpu": "",
"memory": "",
"gpu": "",
"gpu": "GPU",
"network": ""
}
},
@@ -3432,7 +3446,7 @@
"cpu": "",
"memory": "",
"gpu": "",
"network": "",
"network": "NET",
"up": "",
"down": ""
}
@@ -3518,17 +3532,17 @@
"label": "Show agent version"
},
"columnOrder": {
"label": ""
"label": "Column order"
},
"columnWidths": {
"label": ""
"label": "Column widths"
}
},
"noRecords": "No systems found",
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
"layoutSaveTitle": "Unable to save table layout",
"layoutSaveMessage": "Your column order or widths could not be saved. Please try again."
},
"column": {
"system": "System",
@@ -3971,6 +3985,38 @@
"error": {
"internalServerError": "Failed to fetch Traefik data"
}
},
"wud": {
"name": "What's Up Docker",
"description": "Show how many watched containers have updates available.",
"title": "What's Up Docker",
"monitored": "Monitored",
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
"updateAvailable": "Update available",
"openReleaseNotes": "Open release notes",
"option": {
"showTitle": {
"label": "Show title"
},
"showRing": {
"label": "Show update ring"
},
"layout": {
"label": "Layout",
"option": {
"horizontal": {
"label": "Horizontal"
},
"vertical": {
"label": "Vertical"
}
}
},
"showUpdateList": {
"label": "Show container update list",
"description": "Lists each monitored container that has an image update available"
}
}
}
},
"widgetPreview": {
@@ -4710,7 +4756,7 @@
"title": "",
"status": {
"idle": "",
"running": "",
"running": "Running",
"error": "",
"disabled": ""
},
@@ -5176,7 +5222,7 @@
},
"remove": {
"label": "",
"confirm": "",
"confirm": "Click again to remove permanently",
"notification": {
"success": {
"title": "",
@@ -5603,7 +5649,7 @@
}
},
"search": {
"placeholder": "",
"placeholder": "Search for anything",
"nothingFound": "",
"error": {
"fetch": ""
+84 -1
View File
@@ -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."
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+111 -65
View File
@@ -91,7 +91,7 @@
},
"settings": {
"title": "Asetukset",
"subtitle": "Määritä palvelinasetukset."
"subtitle": "Määritä palvelimen asetukset."
},
"integrations": {
"title": "Integraatiot",
@@ -110,7 +110,7 @@
"message": "Jotain meni pieleen kojelautaasi määritettäessä. Yritä uudelleen."
},
"helpers": {
"tooltip": "URL-apurit",
"tooltip": "URL-avustajat",
"description": "Nämä apurit pyrkivät arvaamaan palveluidesi URL-osoitteet. Jos tämä ei toimi, kirjoita osoite manuaalisesti."
}
},
@@ -131,7 +131,7 @@
"summary": "Homarr havaitse {apps} sovellusta, joista {integrations} löytyy sisäänrakennettu integraatio",
"appBadge": "Sovellus",
"appSectionLabel": "Ohjauspaneeliin lisättävät sovellukset ({count} valittu)",
"scanning": "Dockeria tutkitaan...",
"scanning": "Tutkitaan Dockeria...",
"scanningDescription": "Tunnistetaan järjestelmässäsi suoritettavia kontteja"
}
},
@@ -159,7 +159,7 @@
},
"invite": {
"title": "Liity Homarriin",
"subtitle": "Tervetuloa Homarriin! Luo itsellesi käyttäjätunnus",
"subtitle": "Tervetuloa Homarriin! Luo käyttäjätunnuksesi",
"description": "Sinut kutsui {username}"
},
"init": {
@@ -218,7 +218,7 @@
"action": {
"login": {
"label": "Kirjaudu",
"labelWith": "Kirjaudu {{provider}}-tunnuksella",
"labelWith": "Kirjaudu palvelulla: {provider}",
"notification": {
"success": {
"title": "Kirjautuminen onnistui",
@@ -302,7 +302,7 @@
"changeDdgBangs": {
"notification": {
"success": {
"message": "DuckDuckGo Bangs -komentojen tila vaihdettiin"
"message": "DuckDuckGon Bangs-komentojen tila vaihdettiin"
},
"error": {
"message": "DuckDuckGo Bangs -komentojen tilaa ei voitu vaihtaa"
@@ -397,7 +397,7 @@
},
"use-all": {
"label": "Sovellusten käyttö",
"description": "Sallii jäsenten lisätä kaikkia sovelluksia paneeleihinsa."
"description": "Sallii jäsenten lisätä kaikkia sovelluksia paneeleihinsa"
},
"modify-all": {
"label": "Sovellusten muokkaus",
@@ -414,19 +414,19 @@
"item": {
"create": {
"label": "Paneelien luonti",
"description": "Sallii jäsenten luoda paneeleita."
"description": "Sallii jäsenten luoda paneeleita"
},
"view-all": {
"label": "Paneelien tarkastelu",
"description": "Sallii jäsenten tarkastella kaikkia paneeleita."
"description": "Sallii jäsenten tarkastella kaikkia paneeleita"
},
"modify-all": {
"label": "Paneelien muokkaus",
"description": "Sallii jäsenten muokata kaikkia paneeleita (pois lukien käyttöoikeudet ja vaaravyöhyke)."
"description": "Sallii jäsenten muokata kaikkia paneeleita (pois lukien käyttöoikeudet ja vaaravyöhyke)"
},
"full-all": {
"label": "Paneelien hallinta",
"description": "Sallii jäsenten tarkastella, muokata ja poistaa kaikkia paneeleita (mukaan lukien käyttöoikeudet ja vaaravyöhyke)."
"description": "Sallii jäsenten tarkastella, muokata ja poistaa kaikkia paneeleita (mukaan lukien käyttöoikeudet ja vaaravyöhyke)"
}
}
},
@@ -791,7 +791,7 @@
"grid": {
"widgetCount": "{count} widgettiä",
"noWidgets": "Widgettejä ei ole",
"detected": "Havaittu Dockerin kautta"
"detected": "Havaittu Dockerilla"
},
"category": {
"dnsHole": "DNS",
@@ -1023,7 +1023,7 @@
},
"tooManyRequests": {
"title": "",
"message": "Pyyntöjä oli liikaa. Pyyntöjäsi todennäköisesti rajoitetaan tai sinut estetään kohdejärjestelmässä."
"message": "Pyyntöjä oli liikaa. Pyyntöjäsi todennäköisesti rajoitetaan tai sinut estetään kohdejärjestelmässä"
}
}
},
@@ -1049,8 +1049,8 @@
"newLabel": "Uusi käyttäjätunnus"
},
"apiKey": {
"label": "Rajapinnan avain",
"newLabel": "Uusi rajapinnan avain"
"label": "API-avain",
"newLabel": "Uusi API-avain"
},
"password": {
"label": "Salasana",
@@ -1150,6 +1150,10 @@
"bazarr": {
"name": "Bazarr",
"description": "Sonarrin ja Radarrin kumppanisovellus, joka hallinnoi ja lataa tekstityksiä"
},
"wud": {
"name": "Mikä Ylös Docker",
"description": "Tarkkaile missä Docker-säiliöissä on saatavilla kuvapäivityksiä"
}
},
"media": {
@@ -1232,10 +1236,20 @@
"here": "",
"iconPicker": {
"label": "Kuvakkeen URL-osoite",
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta.",
"headerLoading": "Kirjoita hakeaksesi kuvakkeita…",
"header": "Etsi {countIcons} kuvaa nimen mukaan",
"headerLoading": "Hae kuvia nimen mukaan",
"noResults": "Ei kuvakkeita haulla \"{search}\".",
"error": "Kuvakkeiden lataaminen epäonnistui."
"error": "Kuvakkeiden lataaminen epäonnistui.",
"description": "Etsi kirjastosta, liitä kuvan URL-osoite tai lataa tiedosto.",
"clear": "Tyhjennä kuva",
"uploadImage": "Lataa kuva",
"results": "Haun tulokset",
"localImages": "Omat kuvat",
"svgIcons": "SVG kuvakkeet",
"otherImages": "Muut kuvat",
"urlLoading": "Tarkistetaan kuvaa…",
"urlReady": "Kuva on valmis käytettäväksi",
"urlError": "Kuvaa ei voitu ladata. Tarkista URL-osoite ja yritä uudelleen."
},
"colorScheme": {
"options": {
@@ -2034,7 +2048,7 @@
},
"showCurrentWindSpeed": {
"label": "Näytä nykyinen tuulen nopeus",
"description": "Vain nykyiselle säälle."
"description": "Vain nykyiselle säälle"
},
"useImperialSpeed": {
"label": "Käytä tuulen nopeudelle mph-yksikköä"
@@ -2225,19 +2239,19 @@
"label": "Käänteinen järjestys"
},
"columnOrder": {
"label": ""
"label": "Sarakkeiden järjestys"
},
"columnWidths": {
"label": ""
"label": "Sarakkeiden leveydet"
}
},
"error": {
"internalServerError": "",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
"layoutSaveTitle": "Taulukon asettelua ei tallennettu",
"layoutSaveMessage": "Docker-taulukon asettelua ei voitu tallentaa. Yritä siirtää tai muuttaa sarakkeen kokoa uudelleen."
},
"empty": {
"noContainers": ""
"noContainers": "Kontteja ei löytynyt"
}
},
"coolify": {
@@ -2533,17 +2547,17 @@
"description": ""
},
"showBitrate": {
"label": "",
"description": ""
"label": "Näytä bittinopeus",
"description": "Näytä nykyinen toiston bittinopeus sijainnin vieressä"
},
"showLocation": {
"label": "",
"description": ""
"label": "Näytä sijainti",
"description": "Näytä, onko suoratoisto LAN- vai WAN-verkossa."
}
},
"footer": {
"streams": "",
"totalBitrate": ""
"streams": "{count, plural, one {# aktiivinen suoratoisto} other {# aktiivista suoratoistoa}}",
"totalBitrate": "Yhteensä: {bitrate}"
},
"items": {
"currentlyPlaying": "",
@@ -2552,9 +2566,9 @@
"id": "",
"status": "Tila",
"directPlay": "Muuntamaton toisto",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"directStream": "Suora suoratoisto",
"transcodeVideo": "Videon transkoodaus",
"transcodeAudio": "Äänen transkoodaus",
"transcoding": "Transkoodaus",
"paused": "Keskeytetty",
"remaining": "{minutes} min jäljellä",
@@ -2712,21 +2726,21 @@
},
"limitPerIntegration": {
"label": "Integraation elementtien määrärajoitus",
"description": "Tämä rajoittaa näytettävien elementtien määrää integraatiokohtaisesti, ei kokonaisvaltaisesti."
"description": "Tämä rajoittaa näytettävien elementtien määrää integraatiokohtaisesti, ei kokonaisvaltaisesti"
},
"columnOrder": {
"label": ""
"label": "Sarakkeiden järjestys"
},
"columnWidths": {
"label": ""
"label": "Sarakkeiden leveydet"
}
},
"errors": {
"noColumns": "Valitse kohteiden sarakkeet",
"noCommunications": "Integraatiolta ei voida noutaa tietoja",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
"noItems": "Latauksia ei löytynyt",
"actionFailed": "Toiminto epäonnistui",
"actionFailedMessage": "Toimintoa ei voitu suorittaa. Yritä uudelleen."
},
"items": {
"actions": {
@@ -2750,7 +2764,7 @@
},
"id": {
"columnTitle": "Tunniste",
"detailsTitle": ""
"detailsTitle": "Kohteen ID"
},
"integration": {
"columnTitle": "Integraatio"
@@ -2785,7 +2799,7 @@
"time": {
"columnTitle": "Valmistumisaika",
"detailsTitle": "",
"eta": ""
"eta": "Arvioitu aika"
},
"type": {
"columnTitle": "Tyyppi",
@@ -2831,21 +2845,21 @@
},
"globalRatio": "Kokonaissuhde",
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
"copyName": "Kopioi nimi",
"copyId": "Kopioi ID",
"clearFilters": "Tyhjennä suodattimet"
},
"footer": {
"filter": ""
"filter": "Suodata latauksia"
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
"show": "Näytä globaalit tilastot",
"hide": "Piilota globaalit tilastot",
"overallProgress": "Jonon kokonaisedistyminen",
"totalSize": "Jonon kokonaiskoko",
"clients": "{count, plural, one {# asiakas} other {# asiakasta}}"
},
"disconnected": ""
"disconnected": "(ei yhteydessä)"
},
"mediaReleases": {
"name": "",
@@ -3278,7 +3292,7 @@
"unhealthy": "",
"ok": "",
"fail": "",
"noSectionsEnabled": "Aktivoi ainakin yksi osio widgetin asetuksista.",
"noSectionsEnabled": "Ota ainakin yksi osio käyttöön widgetin asetuksista.",
"error": {
"internalServerError": ""
}
@@ -3493,17 +3507,17 @@
"label": "Näytä agentin versio"
},
"columnOrder": {
"label": ""
"label": "Sarakkeiden järjestys"
},
"columnWidths": {
"label": ""
"label": "Sarakkeiden leveydet"
}
},
"noRecords": "Järjestelmiä ei löytynyt",
"error": {
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi.",
"layoutSaveTitle": "",
"layoutSaveMessage": ""
"layoutSaveTitle": "Taulukon asettelua ei voitu tallentaa",
"layoutSaveMessage": "Sarakkeiden järjestystä tai leveyksiä ei voitu tallentaa. Yritä uudelleen."
},
"column": {
"system": "Järjestelmä",
@@ -3946,6 +3960,38 @@
"error": {
"internalServerError": "Traefikin tietojen nouto epäonnistui"
}
},
"wud": {
"name": "Mikä Ylös Docker",
"description": "Näytä kuinka monta katsottua astiaa on saatavilla päivityksiä.",
"title": "Mikä Ylös Docker",
"monitored": "Valvottu",
"updatesAvailable": "{count, plural, one {# päivitys saatavilla} other {# päivitystä saatavilla}}",
"updateAvailable": "Päivitys saatavilla",
"openReleaseNotes": "Avaa julkaisutiedot",
"option": {
"showTitle": {
"label": "Näytä otsikko"
},
"showRing": {
"label": "Näytä päivitysrengas"
},
"layout": {
"label": "Asettelu",
"option": {
"horizontal": {
"label": "Vaaka"
},
"vertical": {
"label": "Pysty"
}
}
},
"showUpdateList": {
"label": "Näytä kontin päivityslista",
"description": "Luettele jokaisen seuratun säiliön joka on saatavilla päivityksessä"
}
}
}
},
"widgetPreview": {
@@ -4152,7 +4198,7 @@
},
"itemRadius": {
"label": "Kohteen säde",
"description": "Muuttaa paneelin korttien kulmien pyöristystä.",
"description": "Muuttaa paneelin korttien pyöreyttä",
"option": {
"xs": "Erittäin pieni",
"sm": "Pieni",
@@ -4697,7 +4743,7 @@
"label": "Analytiikka"
},
"ping": {
"label": "Ping-koestukset"
"label": "Pingit"
}
},
"interval": {
@@ -5062,7 +5108,7 @@
"refresh": {
"lastUpdated": "Viimeisin päivitys {when}"
},
"search": "Suodata {count} konttia...",
"search": "Suodata {count} konttia",
"selected": "{selectCount}/{totalCount} kontista valittu",
"footer": "Yhteensä {count} konttia",
"totalCpu": "CPU yhteensä: {cpu}%",
@@ -5151,7 +5197,7 @@
},
"remove": {
"label": "Poista",
"confirm": "",
"confirm": "Napsauta uudelleen poistaaksesi pysyvästi",
"notification": {
"success": {
"title": "",
@@ -5684,14 +5730,14 @@
"title": "Asetukset",
"children": {
"detail": {
"title": "Valitse muutettava asetus.",
"title": "Valitse muutettava asetus",
"backHint": "Palaa takaisin askelpalauttimella tai painamalla Takaisin-painiketta",
"backAction": "Palaa takaisin"
}
},
"notification": {
"error": {
"message": "Asetusta ei voitu päivittää."
"message": "Asetusta ei voitu päivittää"
}
},
"option": {
@@ -5775,10 +5821,10 @@
}
},
"pingIconsEnabled": {
"label": "Käytä ping-kuvakkeita",
"label": "Käytä ping-ikoneita",
"children": {
"detail": {
"title": "Käytä ping-kuvakkeita"
"title": "Käytä ping-ikoneita"
}
}
},
@@ -5957,7 +6003,7 @@
"label": "Hallinnoi Dockeria"
},
"manageApi": {
"label": "Swagger-rajapinta"
"label": "Swagger API"
},
"manageLog": {
"label": "Tarkastele lokeja"
+223 -177
View File
@@ -4,7 +4,7 @@
"start": {
"title": "Bienvenue sur Homarr",
"subtitle": "Commençons par configurer votre instance Homarr.",
"description": "Pour commencer, veuillez selectionner comment vous souhaitez configurer votre instance Homarr.",
"description": "Pour commencer, veuillez sélectionner comment vous souhaitez configurer votre instance Homarr.",
"action": {
"scratch": "Partir de zéro",
"importOldmarr": "Importer depuis Homarr avant la 1.0",
@@ -129,7 +129,7 @@
"detected": "Docker a détecté {count} services",
"detectedBreakdown": "Intégrations {integrations} · Applications {apps}",
"summary": "Les applications {apps} ont été détectées par Homarr, dont {integrations} ont des intégrations intégrées",
"appBadge": "App",
"appBadge": "Application",
"appSectionLabel": "Applications à ajouter au tableau de bord ({count} sélectionné)",
"scanning": "Analyse de Docker...",
"scanningDescription": "Recherche de conteneurs en cours d'exécution sur votre système"
@@ -604,7 +604,7 @@
}
},
"app": {
"search": "Trouver une application",
"search": "Trouver une app",
"page": {
"list": {
"title": "Applications",
@@ -673,7 +673,7 @@
},
"action": {
"select": {
"label": "Sélectionner l'app",
"label": "Sélectionner une app",
"notFound": "Aucune app trouvée",
"search": "Rechercher une application",
"noResults": "Pas de résultats",
@@ -1016,15 +1016,15 @@
},
"invalidUrl": {
"title": "URL invalide",
"message": "L'URL est invalide."
"message": "L'URL est invalide"
},
"secretNotDefined": {
"title": "Identifiants manquants",
"message": "Les informations d'identification n'ont pas toutes été fournies"
"message": "Tous les identifiants n'ont pas été fournis"
},
"invalidCredentials": {
"title": "Informations d'identification incorrectes.",
"message": "Les informations d'identification ne sont pas valides"
"title": "Les identifiants sont invalides",
"message": "Les identifiants sont invalides"
},
"commonError": {
"title": "Échec de la connexion",
@@ -1138,12 +1138,12 @@
"newLabel": "Nouvelle clé API (secrète)"
},
"patchmonApiKey": {
"label": "",
"newLabel": ""
"label": "Clé API",
"newLabel": "Nouvelle clé API"
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
"label": "Clé secrète d'API",
"newLabel": "Nouveau secret API"
},
"githubAppId": {
"label": "Identifiant de l'application",
@@ -1169,15 +1169,15 @@
"full": "Accès intégral à l'intégration"
},
"audiobookshelf": {
"name": "",
"description": ""
"name": "Étagère de livres audio",
"description": "Serveur de livres audio et de podcasts auto-hébergés"
},
"navidrome": {
"name": "Navidrome",
"description": ""
"description": "Serveur de streaming de musique auto-hébergé avec compatibilité avec l'API Subsonic"
},
"uptimeKuma": {
"name": "",
"name": "Uptime Kuma",
"description": "",
"secret": {
"slug": {
@@ -1189,20 +1189,24 @@
}
},
"traefik": {
"name": "",
"name": "Traefik",
"description": ""
},
"paperlessNgx": {
"name": "",
"name": "Paperless-ngx",
"description": ""
},
"patchmon": {
"name": "",
"description": ""
"name": "PatchMon",
"description": "Gestion et surveillance des correctifs pour les hôtes Linux"
},
"bazarr": {
"name": "",
"name": "Bazarr",
"description": ""
},
"wud": {
"name": "Qu'est ce que Docker",
"description": "Surveiller lequel de vos conteneurs Docker a des mises à jour d'images disponibles"
}
},
"media": {
@@ -1287,11 +1291,21 @@
},
"here": "ici",
"iconPicker": {
"label": "URL de l'icône :",
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous.",
"headerLoading": "Tapez pour rechercher les icônes…",
"label": "URL de l'icône ",
"header": "Rechercher les images {countIcons} par nom",
"headerLoading": "Rechercher des images par nom",
"noResults": "Aucune icône ne correspond à \"{search}\".",
"error": "Impossible de charger les icônes."
"error": "Impossible de charger les icônes.",
"description": "Recherchez dans la bibliothèque, collez une URL d'image, ou téléchargez un fichier.",
"clear": "Effacer l'image",
"uploadImage": "Charger une image",
"results": "Résultats de la recherche d'images",
"localImages": "Vos images",
"svgIcons": "Icônes SVG",
"otherImages": "Autres images",
"urlLoading": "Vérification de l'image…",
"urlReady": "L'image est prête à être utilisée",
"urlError": "Cette image n'a pas pu être chargée. Vérifiez l'URL et réessayez."
},
"colorScheme": {
"options": {
@@ -1479,10 +1493,10 @@
"actions": "Actions"
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
"success": "OK",
"error": "Erreur",
"loading": "Chargement",
"idle": "Inactif"
}
},
"create": {
@@ -1636,7 +1650,7 @@
}
},
"archiveTeamWarrior": {
"name": "",
"name": "ArchiveTeam Warrior",
"description": "",
"option": {
"showBroadcastMessage": {
@@ -1758,14 +1772,14 @@
"description": "Utiliser ISO 8601 pour formater la date (cela remplacera les autres options)"
},
"showWeather": {
"label": "",
"description": ""
"label": "Afficher la météo",
"description": "Afficher une petite icône météo animée et la température dans le coin de la carte"
},
"weatherLocation": {
"label": ""
"label": "Lieu de la météo"
},
"isWeatherFormatFahrenheit": {
"label": ""
"label": "Utiliser les degrés Fahrenheit"
}
}
},
@@ -1795,7 +1809,7 @@
"noStation": "Aucune station sélectionnée",
"option": {
"baseUrl": {
"label": ""
"label": "URL Search.ch"
},
"station": {
"label": "Station"
@@ -1827,8 +1841,8 @@
"clear": "Effacer la mise en forme",
"heading": "Titre {level}",
"align": "Aligner le texte : {position}",
"directionLtr": "",
"directionRtl": "",
"directionLtr": "Définir la direction du texte de gauche à droite",
"directionRtl": "Définir la direction du texte de droite à gauche",
"blockquote": "Citation",
"horizontalLine": "Ligne horizontale",
"details": "Afficher ou masquer la section repliable",
@@ -1903,7 +1917,7 @@
"edit": "Modifier",
"save": "Sauvegarder",
"cancel": "Annuler",
"readOnlyViewer": "",
"readOnlyViewer": "Vous avez un accès en lecture seule à cette note. La modification est désactivée.",
"saveForbidden": "Vous n'êtes pas autorisé à modifier cette note.",
"saveFailed": "Impossible d'enregistrer cette note. Veuillez réessayer."
},
@@ -2118,13 +2132,13 @@
"description": "À quoi devrait ressembler la date"
}
},
"currentWindSpeed": "{currentWindSpeed} km/h",
"currentWindSpeed": "{currentWindSpeed} {unit}",
"dailyForecast": {
"sunrise": "Lever du Soleil",
"sunset": "Coucher du soleil",
"maxWindSpeed": "Vitesse maximale du vent : {maxWindSpeed} km/h",
"maxWindGusts": "Rafales de vent maximales : {maxWindGusts} km/h",
"humidity": ""
"maxWindSpeed": "Vitesse maximale du vent : {maxWindSpeed} {unit}",
"maxWindGusts": "Rafales de vent maximales : {maxWindGusts} {unit}",
"humidity": "Humidité : {humidity}%"
},
"kind": {
"clear": "Clair",
@@ -2184,9 +2198,9 @@
"label": "Afficher les infos sur le système de fichiers"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
"label": "Volumes de stockage visibles",
"description": "Uniquement disponible lorsque toutes les intégrations sélectionnées sont Synology. Laissez vide pour afficher tous les volumes de stockage détectés.",
"placeholder": "Sélectionnez les volumes de stockage"
},
"defaultTab": {
"label": "Onglet par défaut"
@@ -2208,7 +2222,7 @@
"loadAverage": "Charge moyenne :",
"minute": "1 minute",
"minutes": "{count} minutes",
"used": "Utilisé ",
"used": "Utilisé",
"available": "Disponible",
"lastSeen": "Dernière mise à jour de statut : {lastSeen}"
},
@@ -2251,7 +2265,7 @@
"cpu": "Coeurs",
"memory": "Mémoire",
"storage": "Stockage",
"uptime": "Disponibilité",
"uptime": "Disponible",
"haState": "État HA",
"storageType": {
"local": "Stockage local",
@@ -2274,7 +2288,7 @@
"description": "Statistiques de vos conteneurs (Ce widget ne peut être ajouté qu'avec les privilèges d'administrateur)",
"option": {
"columns": {
"label": ""
"label": "Colonnes à afficher"
},
"enableRowSorting": {
"label": "Activer le tri des éléments"
@@ -2292,19 +2306,19 @@
"label": "Inverser le tri"
},
"columnOrder": {
"label": ""
"label": "Ordre des colonnes"
},
"columnWidths": {
"label": ""
"label": "Largeurs des colonnes"
}
},
"error": {
"internalServerError": "Impossible de récupérer les statistiques des conteneurs",
"layoutSaveTitle": "",
"layoutSaveTitle": "Disposition du tableau non sauvegardée",
"layoutSaveMessage": ""
},
"empty": {
"noContainers": ""
"noContainers": "Aucun conteneur trouvé"
}
},
"coolify": {
@@ -2341,9 +2355,9 @@
}
},
"vpn": {
"name": "",
"description": "",
"serviceUnavailable": "",
"name": "VPN",
"description": "Aperçu de vos connexions VPN",
"serviceUnavailable": "VPN indisponible",
"option": {}
},
"ups": {
@@ -2600,31 +2614,31 @@
"description": "Désactiver cette option ne fonctionnera pas pour Plex"
},
"showBitrate": {
"label": "",
"label": "Afficher le débit",
"description": ""
},
"showLocation": {
"label": "",
"label": "Afficher la localisation",
"description": ""
}
},
"footer": {
"streams": "",
"totalBitrate": ""
"totalBitrate": "Total : {bitrate}"
},
"items": {
"currentlyPlaying": "En cours de lecture",
"user": "Utilisateur",
"name": "Nom",
"id": "Id",
"status": "",
"directPlay": "",
"directStream": "",
"transcodeVideo": "",
"transcodeAudio": "",
"transcoding": "",
"paused": "",
"remaining": "",
"status": "Statut",
"directPlay": "Lecture directe",
"directStream": "Flux direct",
"transcodeVideo": "Transcodage vidéo",
"transcodeAudio": "Transcodage audio",
"transcoding": "Transcodage",
"paused": "En pause",
"remaining": "{minutes} min restantes",
"metadata": {
"title": "Statistiques détaillées",
"video": {
@@ -2650,44 +2664,44 @@
"description": "Aperçu de votre archive de documents Paperless-ngx",
"documentsTotal": "Nombre total de documents",
"documentsInbox": "Boîte de réception",
"correspondents": "",
"tags": "",
"documentTypes": "",
"inboxRatio": "",
"inbox": "",
"total": "",
"correspondents": "Correspondants",
"tags": "Tags",
"documentTypes": "Types de documents",
"inboxRatio": "Ratio de la boîte de réception",
"inbox": "boîte de réception",
"total": "total",
"option": {
"showInboxRatio": {
"label": "Afficher le ratio de la boîte de réception",
"description": ""
"description": "Afficher le rapport inbox/total des documents dans la section Héros"
},
"showInboxRing": {
"label": "",
"description": ""
"label": "Afficher l'anneau de la boîte de réception",
"description": "Afficher un anneau de progression à côté du ratio de la boîte de réception"
},
"showDocumentsTotal": {
"label": "",
"description": ""
"label": "Afficher le total des documents",
"description": "Afficher le nombre total de documents dans votre archive"
},
"showDocumentsInbox": {
"label": "",
"description": ""
"label": "Afficher le compteur de la boîte de réception",
"description": "Afficher le nombre de documents non traités dans la boîte de réception"
},
"showCorrespondents": {
"label": "",
"description": ""
"label": "Afficher les correspondants",
"description": "Afficher le nombre total de correspondants"
},
"showTags": {
"label": "",
"description": ""
"label": "Afficher les tags",
"description": "Afficher le nombre total de tags"
},
"showDocumentTypes": {
"label": "",
"description": ""
"label": "Afficher les types de documents",
"description": "Afficher le nombre total de types de documents"
}
},
"error": {
"internalServerError": ""
"internalServerError": "Échec de la récupération des données Paperless-ngx"
}
},
"immich-serverStats": {
@@ -2722,7 +2736,7 @@
"option": {
"albumId": {
"label": "Identifiant de l'album",
"description": "C'est l'identifiant unique qui correspond à l'album Immich. Vous pouvez le trouver en ouvrant un album et copiant l'identifiant unique depuis l'URL, par exemple 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
"description": "C'est l'identifiant unique qui correspond à l'album Immich. Vous pouvez le trouver en ouvrant un album et copiant l'identifiant unique depuis l'URL, par exemple 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
},
"rotationIntervalSeconds": {
"label": "Intervalle en secondes",
@@ -2782,18 +2796,18 @@
"description": "Cela limitera le nombre d'éléments affichés par intégration, pas globalement"
},
"columnOrder": {
"label": ""
"label": "Ordre des colonnes"
},
"columnWidths": {
"label": ""
"label": "Largeur des colonnes"
}
},
"errors": {
"noColumns": "Sélectionner les colonnes dans les éléments",
"noCommunications": "Impossible de charger les données à partir de l'intégration",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
"noItems": "Aucun téléchargement trouvé",
"actionFailed": "Action échouée",
"actionFailedMessage": "Cette action n'a pas pu être complétée. Veuillez ressayer."
},
"items": {
"actions": {
@@ -2987,7 +3001,7 @@
"option": {},
"titles": {
"stats": {
"main": "Statistiques des médias",
"main": "Stats des médias",
"approved": "Déjà approuvé",
"pending": "En attente de validation",
"processing": "En cours de traitement",
@@ -3057,7 +3071,7 @@
"file": "Fichier",
"size": "Taille",
"transcode": "Transcodeur",
"healthCheck": "Bilan de santé"
"healthCheck": "Contrôle de santé"
}
}
}
@@ -3194,9 +3208,9 @@
"not-found": "Non trouvé",
"pre-release": "Pré-publication",
"archived": "Archivé",
"forked": "",
"forked": "Forké",
"starsCount": "Étoiles",
"forksCount": "",
"forksCount": "Forks",
"issuesCount": "Problèmes ouverts",
"markViewed": "Marquer comme vu(s)",
"openProjectPage": "Ouvrir la page du projet",
@@ -3307,7 +3321,7 @@
"noItems": "Aucune notification à afficher.",
"option": {
"hideLogos": {
"label": ""
"label": "Masquer les logos"
}
}
},
@@ -3351,7 +3365,7 @@
}
},
"uptimeKuma": {
"name": "",
"name": "Uptime Kuma",
"description": "",
"option": {
"showAverageUptime": {
@@ -3576,15 +3590,15 @@
"system": "",
"cpu": "",
"memory": "",
"disk": "",
"disk": "Disque",
"gpu": "",
"loadAvg": "",
"net": "",
"temp": "",
"temp": "Temp",
"battery": "",
"services": "",
"uptime": "",
"agent": ""
"agent": "Agent"
}
},
"beszelSystemGrid": {
@@ -3636,7 +3650,7 @@
},
"metric": {
"cpu": "",
"memory": "",
"memory": "Mémoire",
"disk": "",
"gpu": "",
"loadAvg": "",
@@ -3663,8 +3677,8 @@
"error": {
"internalServerError": ""
},
"history": "",
"resolved": "",
"history": "Historique",
"resolved": "Résolu",
"status": {
"triggered": "",
"ok": ""
@@ -3719,12 +3733,12 @@
"chart": {
"cpu": {
"title": "",
"subtitle": "",
"series": ""
"subtitle": "Utilisation moyenne du CPU à l'échelle du système",
"series": "CPU"
},
"memory": {
"title": "",
"subtitle": "",
"title": "Utilisation Mémoire",
"subtitle": "Utilisation précise au moment enregistré",
"series": "",
"cache": ""
},
@@ -3735,7 +3749,7 @@
},
"diskIO": {
"title": "",
"subtitle": "",
"subtitle": "Débit du système de fichiers root",
"read": "",
"write": ""
},
@@ -3765,70 +3779,70 @@
}
},
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"name": "PatchMon",
"description": "Aperçu de l'état des correctifs des hôtes depuis PatchMon",
"title": "Statistiques PatchMon",
"totalHosts": "Total des hôtes",
"hostsNeedingUpdates": "Mises à jour requises",
"securityUpdates": "Mises à jour de sécurité",
"upToDateHosts": "Hôtes à jour",
"hostsWithSecurityUpdates": "Hôtes avec mises à jour de sécurité",
"recentUpdates24h": "Mises à jour (24h)",
"totalOutdatedPackages": "Paquets obsolètes",
"totalRepos": "Dépôts",
"osDistribution": "Répartition des OS",
"compliance": "Conformité des correctifs",
"complianceRatio": "{upToDate} / {total}",
"lastUpdated": "Mis à jour {time}",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
"label": "Afficher le nombre total d'hôtes",
"description": "Afficher le nombre total d'hôtes surveillés"
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
"label": "Afficher les hôtes ayant besoin de mises à jour",
"description": "Afficher les hôtes avec au moins un paquet obsolète"
},
"showSecurityUpdates": {
"label": "",
"description": ""
"label": "Afficher les mises à jour de sécurité",
"description": "Afficher le nombre total de mises à jour de sécurité disponibles"
},
"showUpToDateHosts": {
"label": "",
"description": ""
"label": "Afficher les hôtes à jour",
"description": "Afficher les hôtes avec aucun paquet obsolète"
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
"label": "Afficher les hôtes avec les mises à jour de sécurité",
"description": "Afficher les hôtes nécessitant des correctifs de sécurité"
},
"showRecentUpdates24h": {
"label": "",
"description": ""
"label": "Afficher les mises à jour récentes (24h)",
"description": "Afficher le nombre de mises à jour appliquées au cours des dernières 24 heures"
},
"showTotalOutdatedPackages": {
"label": "",
"label": "Afficher les paquets obsolètes",
"description": ""
},
"showTotalRepos": {
"label": "",
"description": ""
"label": "Afficher les dépôts",
"description": "Afficher le nombre de dépôts actifs en cours de surveillance"
},
"enableThresholdColors": {
"label": "",
"label": "Activer les couleurs de seuil",
"description": ""
},
"useCustomThresholds": {
"label": "",
"label": "Seuils avancés",
"description": ""
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
"percent": "Pourcentage du total des hôtes"
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"label": "Nécessite le mode de mise à jour de seuil",
"description": ""
},
"hostsNeedingUpdatesWarningAt": {
@@ -3848,7 +3862,7 @@
"description": ""
},
"securityUpdatesCriticalAt": {
"label": "",
"label": "Seuil critique de mise à jour de sécurité",
"description": ""
},
"hostsWithSecurityUpdatesThresholdMode": {
@@ -3888,33 +3902,33 @@
"description": ""
},
"showOsDistribution": {
"label": "",
"label": "Afficher la répartition des OS",
"description": ""
},
"osDistributionLimit": {
"label": "",
"description": "",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
"3": "Top 3",
"5": "Top 5",
"10": "Top 10",
"all": "Tout"
}
},
"showOsVersion": {
"label": "",
"description": ""
"label": "Afficher la version de l'OS",
"description": "Ajouter la version de l'OS à chaque étiquette de distribution lorsque disponible"
},
"showComplianceHero": {
"label": "",
"label": "Afficher la jauge de conformité",
"description": ""
},
"osDisplayMode": {
"label": "",
"description": "",
"label": "Mode d'affichage de l'OS",
"description": "Choisir comment afficher la répartition des systèmes d'exploitation",
"option": {
"bars": "",
"donut": ""
"bars": "Barres horizontales",
"donut": "Graphique en anneau"
}
}
},
@@ -3923,19 +3937,19 @@
}
},
"bazarr": {
"name": "",
"name": "Bazarr",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"episodes": "Épisodes manquants",
"movies": "Films manquants",
"providers": "Problèmes de fournisseurs",
"status": "",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
"label": "Afficher les épisodes manquants",
"description": "Afficher le nombre d'épisodes avec les sous-titres manquants"
},
"showMissingMovies": {
"label": "",
"label": "Afficher les films manquants",
"description": ""
},
"showProviderIssues": {
@@ -3959,26 +3973,26 @@
"label": ""
},
"showQueued": {
"label": ""
"label": "Afficher les éléments en attente"
},
"pageSize": {
"label": ""
"label": "Éléments par page"
}
},
"tab": {
"missing": "",
"queued": ""
"queued": "En file d’attente"
},
"type": {
"movie": "",
"episode": ""
"movie": "Film",
"episode": "Épisode"
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
"missing": "Aucun élément manquant",
"queued": "Aucun élément en file d'attente",
"noTabsEnabled": "Activer au moins un onglet dans les paramètres du widget"
},
"timeLeft": ""
"timeLeft": "Temps restant"
},
"traefik": {
"name": "",
@@ -4002,7 +4016,7 @@
},
"resource": {
"routers": "",
"services": "",
"services": "Services",
"middlewares": ""
},
"status": {
@@ -4013,6 +4027,38 @@
"error": {
"internalServerError": ""
}
},
"wud": {
"name": "Qu'est ce que Docker",
"description": "Afficher combien de conteneurs surveillés ont des mises à jour disponibles.",
"title": "Qu'est ce que Docker",
"monitored": "Surveillé",
"updatesAvailable": "{count, plural, one {# mise à jour disponible} other {# mises à jour disponibles}}",
"updateAvailable": "Mise à jour disponible",
"openReleaseNotes": "Ouvrir les notes de version",
"option": {
"showTitle": {
"label": "Afficher le titre"
},
"showRing": {
"label": "Afficher l'anneau de mise à jour"
},
"layout": {
"label": "Mise en page",
"option": {
"horizontal": {
"label": "Horizontale"
},
"vertical": {
"label": "Vertical"
}
}
},
"showUpdateList": {
"label": "Afficher la liste de mise à jour du conteneur",
"description": "Liste chaque conteneur surveillé qui a une mise à jour d'image disponible"
}
}
}
},
"widgetPreview": {
@@ -5276,7 +5322,7 @@
"title": "Capacité",
"resource": {
"reserved": "Réservé",
"used": "Utilisé "
"used": "Utilisé"
}
},
"resources": {
@@ -5565,7 +5611,7 @@
"label": "Médias"
},
"apps": {
"label": "Applications",
"label": "Apps",
"new": {
"label": "Nouveau"
},
@@ -5648,7 +5694,7 @@
"placeholder": "Rechercher quelque chose",
"nothingFound": "Aucun résultat",
"error": {
"fetch": "Une erreur s'est produite lors de la récupération des données."
"fetch": "Une erreur s'est produite lors de la récupération des données"
},
"mode": {
"appIntegrationBoard": {
@@ -6331,7 +6377,7 @@
},
"editMode": {
"title": "Mode édition",
"description": "Cliquez ici pour entrer en mode édition. Vous pouvez ensuite ajouter des widgets, des applications et des sections — faites glisser pour repositionner, redimensionnez avec la poignée d'angle. Cliquez à nouveau pour enregistrer vos modifications."
"description": "Cliquez ici pour entrer en mode édition. Vous pourrez ensuite ajouter des widgets, des applications et des sections — faites glisser pour repositionner, redimensionnez avec la poignée d'angle. Cliquez à nouveau pour enregistrer vos modifications."
},
"settings": {
"title": "Paramètres du tableau de bord",
+50 -4
View File
@@ -1150,6 +1150,10 @@
"bazarr": {
"name": "",
"description": ""
},
"wud": {
"name": "וואטס אפ דוקר",
"description": "עקוב אחר אילו מקונטיינרי הדוקר שלך כוללים עדכוני תמונה זמינים"
}
},
"media": {
@@ -1232,10 +1236,20 @@
"here": "כאן",
"iconPicker": {
"label": "קישור של הסמל",
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} .",
"headerLoading": "",
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} ",
"headerLoading": "חפש תמונות לפי שם",
"noResults": "",
"error": ""
"error": "",
"description": "חפש בספרייה, הדבק כתובת URL של תמונה, או העלה קובץ.",
"clear": "נקה תמונה",
"uploadImage": "העלה תמונה",
"results": "תוצאות חיפוש תמונות",
"localImages": "התמונות שלך",
"svgIcons": "סמלי SVG",
"otherImages": "תמונות אחרות",
"urlLoading": "בודק תמונה…",
"urlReady": "התמונה מוכנה לשימוש",
"urlError": "לא ניתן היה לטעון את התמונה. בדוק את כתובת ה-URL ונסה שוב."
},
"colorScheme": {
"options": {
@@ -2390,7 +2404,7 @@
"today": "היום",
"24h": "24 השעות האחרונות",
"7d": "7 הימים האחרונים",
"30d": "",
"30d": "30 הימים האחרונים",
"month": "",
"lastMonth": ""
}
@@ -3946,6 +3960,38 @@
"subtitle": ""
}
}
},
"wud": {
"name": "וואטס אפ דוקר",
"description": "הצג כמה מהמכולות הנצפות עם עדכונים זמינים.",
"title": "What's Up Docker",
"monitored": "מנוטר",
"updatesAvailable": "{count, plural, one {# עדכון זמין} two {# עדכונים זמינים} many {# עדכונים זמינים} other {# עדכונים זמינים}}",
"updateAvailable": "עדכון זמין",
"openReleaseNotes": "פתח הערות שחרור",
"option": {
"showTitle": {
"label": "הצג כותרת"
},
"showRing": {
"label": "הצג טבעת עדכון"
},
"layout": {
"label": "פריסה",
"option": {
"horizontal": {
"label": "אופקי"
},
"vertical": {
"label": "אנכי"
}
}
},
"showUpdateList": {
"label": "הצג רשימת עדכוני מכולות",
"description": "מפרט כל מכולה מנוטרת שיש עדכון תמונה זמין עבורה"
}
}
}
},
"widgetPreview": {
File diff suppressed because it is too large. Load diff
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more