Compare commits

...
Author SHA1 Message Date
Thomas Camlong 68ce7e73c0 fix(open-webui): use current request-handler API to fix the build
The request handlers were written against an older caching API that dev has
since refactored ('TanStack Query as single source of truth'), breaking the
Turbopack build with a module-not-found on ./lib/cached-integration-request-handler.

- use createIntegrationRequestHandler from ./lib/integration-request-handler
- replace cacheDuration(dayjs)/queryKey with cacheTtlMs (ms)
- update the router consumers: getCachedOrUpdatedDataAsync -> getDataAsync and
  per-call invalidateAsync() -> handler-level invalidateCache()
2026-07-11 19:33:42 +02:00
ajnart 87f1f329da style: auto-fix lint and format 2026-07-11 17:12:44 +00:00
Thomas Camlong 9301e224ec feat(open-webui): tag chats per user, harden error/voice states, add tests
- persistence: tag created/updated Open WebUI chats with a homarr-user:<id> tag
  (via chat meta.tags) so conversations can be found back per Homarr user even
  when an integration/account is shared
- voice recording: distinguish a denied/dismissed microphone permission from a
  generic mic failure and show an actionable message
- widget: surface the models query loading and connection-error states in the
  chat empty state instead of silently showing 'no models'
- tests: cover the final-SSE-frame flush, the default request timeout (and the
  streaming opt-out), and multimodal chat parsing round-trip
2026-07-11 19:11:13 +02:00
Thomas Camlong e648abf78e feat(open-webui): header entry, side panel, keybind and spotlight chat
Replace the bottom-right floating chat button with app-wide launchers, as
requested in review:

- side panel: a right-side Drawer (OpenWebUiChat) reusing the chat widget,
  mounted app-wide and shown only to authed users who own an Open WebUI
  integration
- header entry: an 'AI Chat' item in the user-avatar menu (like the Docker item)
- keybind: mod+shift+a toggles the panel (hotkeys.toggleAiChat)
- spotlight: a '?' 'Ask AI' mode that starts a conversation from the typed query
  and opens the panel pre-filled; also surfaced in the Cmd+K help/mode list when
  the integration is present
- the panel and spotlight are bridged to the app via an openWebUiChatEvent
  window event (mirrors the existing media-request search event)

The Chat widget gains an optional initialQuery that auto-sends once on open.
2026-07-11 19:11:12 +02:00
Thomas Camlong 3d2a2d7b55 fix(open-webui): drop MCP exposure and address remaining review comments
Per ajnart's review (and CodeRabbit withdrawing its own MCP comment on the
predecessor PR), the Open WebUI chat isn't a meaningful MCP surface:
- remove the .meta({ mcp }) metadata from every openWebUi procedure

Remaining CodeRabbit comments from the predecessor PR that weren't carried over:
- processWeb: constrain the URL to http(s) and reject loopback/metadata hosts
  (private LAN ranges stay allowed for self-hosters); it is also creator-gated
- uploadFile/transcribe: bound the base64 payload and validate filename/type
- docs: align the example URL with the integration default port (8080)
- new chat: clear the composer draft so it doesn't leak into the fresh chat
- web ingestion: keep the typed URL when ingestion fails
- persistence: fall back to the default title for image-only opening turns, and
  don't persist a partial reply from a failed turn as finished history
- multimodal round-trip: make the native chat/history contracts accept the same
  text-or-parts content union as completions, and flatten (not drop) non-text
  nodes when rebuilding transcripts so attachment-backed chats aren't lost.
  Image data URLs are still kept out of persisted history by design.
2026-07-11 19:11:12 +02:00
ajnart c076a36fec style: auto-fix lint and format 2026-07-11 16:29:31 +00:00
Thomas Camlong 951e707438 fix: address CodeRabbit review comments
API / router (packages/api):
- open-webui: expose serializable procedures as MCP tools via .meta({ mcp })
  and gate knowledge/files/notes reads behind protectedProcedure + interact so
  anonymous board viewers can't enumerate private source content
- integration create: wrap integration + creator-permission + secret writes in
  a single cross-driver transaction so a partial failure can't leave an
  unusable integration
- integration test: scope the permission lookup to the created integration

Integration (packages/integrations):
- open-webui: log only safe metadata (not raw completion chunks) on parse
  failure, flush the final buffered SSE frame before treating the stream as
  truncated, and apply a default request timeout (streaming opts out)

Widget (packages/widgets):
- collapse knowledge rows by default to avoid an N+1 getKnowledgeFiles burst
- strip markdown images from assistant output to block untrusted fetches
- reconcile the selected model when the model list changes
- clear draft attachments when switching to a saved chat
- dedupe async note/chat attaches after the fetch resolves
- discard stale persist responses via a session token so a cleared chat can't
  be rebound to an old Open WebUI chat id
- release the mic stream on mid-start failures and mark the recording cancelled
  on unmount so partial audio isn't transcribed
2026-07-11 18:28:04 +02:00
Thomas Camlong e1ba193ea3 db: regenerate integration.creator_id migrations for all dialects
The PR only shipped a SQLite creator_id migration; MySQL and PostgreSQL were
missing (CodeRabbit) and the SQLite one lacked ON DELETE SET NULL. Regenerate
on top of dev's widget_secret base:

- sqlite 0041: ADD creator_id ... REFERENCES user(id) ON DELETE SET NULL
- mysql 0043: ADD creator_id + FK ON DELETE set null (hand-written snapshot
  from dev's 0042 to avoid touching the pre-existing custom_widget_secret FK
  that must stay under MySQL's 64-char identifier limit)
- postgresql 0009: ADD creator_id + FK ON DELETE set null

Schema already declares onDelete: set null on integration.creatorId.
2026-07-11 18:19:08 +02:00
Thomas Camlong 411c809c55 Merge branch 'dev' into feat/openwebui-integration
Resolve conflicts by keeping both openWebUi and dev-side registry entries
(integration/widget kinds, creators, docs sitemap). Take dev's next.config
turbopack settings. Reset SQLite migration base to dev's 0040_add_widget_secret;
the integration.creator_id migrations are regenerated for all dialects in a
follow-up commit.
2026-07-11 18:11:03 +02:00
Thomas 576856d634 fix(beszel): add reliable live stats and filesystem UI (#6289) 2026-07-11 17:55:04 +02:00
Thomas def3ede3bc fix(db): restore widget_secret migration journal entries accidentally removed by #6206 (#6288) 2026-07-11 15:40:06 +02:00
homarr-releases[bot] d9c503d01b chore: update bug report template (#6281)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-07-10 20:17:43 +00:00
Releases Homarr 82aa905214 chore(release): 1.70.0 [skip ci] 2026-07-10 20:03:12 +00:00
Thomas 4b351bb209 chore(release): automatic release v1.70.0 2026-07-10 22:01:50 +02:00
Thomas 13c9e5e181 fix(release): repair v1.70.0 quality failures (#6279)
* fix(release): repair v1.70.0 quality failures

* style: auto-fix lint and format
2026-07-10 21:59:57 +02:00
homarr-releases[bot] 6b047a9c8e chore(release): automatic release v1.70.0 2026-07-10 18:16:39 +00:00
Thomas 7a768517ef fix(request-handler): restore media organizer build (#6277) 2026-07-10 18:09:05 +02:00
Thomas 4ae8d527a8 fix(releases): token not applied due to broken allowedRepoIds check (#6276) 2026-07-10 17:55:18 +02:00
homarr-update-contributors[bot] 05a48e2930 chore: update integrations list readme (#6275) 2026-07-10 17:50:02 +02:00
Josh PrestonandThomas 33395ae226 feat(integrations): add PatchMon integration and widget (#6119)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:48:43 +02:00
Thomas 174c5f98f8 fix(beszel): disable request handler cache (#6274) 2026-07-10 17:40:54 +02:00
a2cf2d6cff Update Helm chart documentation (#6261)
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-10 17:33:35 +02:00
Releases Homarr 57e18f6ce6 chore(release): 1.69.2 [skip ci] 2026-07-06 08:05:24 +00:00
Thomas 29369e23c1 Merge pull request #6208 from homarr-labs/proxmox-status-zindex
fix(layout): raise AppShellHeader z-index above Proxmox status dots
2026-07-06 10:04:04 +02:00
ajnart 687edb9226 fix(layout): raise AppShellHeader z-index above Indicator dots
The Mantine Indicator component (used by the Proxmox cluster health
widget for VM/LXC status dots) sits at z-index 200, which is higher
than the AppShellHeader's default 100. This caused the status dots
to bleed above the header when scrolling.

Set the header z-index to 201 so it stacks above Indicator content
without conflicting with modal/popover layers (300+).

Closes #6194
2026-07-06 01:50:13 +02:00
Lok3sh-kumar 1b3eb1ccc8 style: auto-fix lint and format 2026-07-01 15:27:12 +00:00
Lok3sh-Kumar 6f6a989ba6 openwebui: enable user scoped integrations to avoid cross user data leakage 2026-07-01 20:55:58 +05:30
Lok3sh-Kumar 3bb6037d35 db: fix sqlite migrations 2026-07-01 20:55:58 +05:30
Lok3sh-kumar c5e31513e2 style: auto-fix lint and format 2026-06-27 09:25:33 +00:00
Lok3sh-Kumar dae1cae894 Update docs sitemap 2026-06-27 14:53:20 +05:30
Lok3sh-Kumar 41b75ef5a0 feat(open-webui): searchable model picker in floating header
Replace the inline Select with a Combobox-based ModelSelect that filters
models via an in-dropdown search field, and move the model picker, new
chat and history controls into a floating header above the messages.
Assistant avatar now uses the Open WebUI brand logo.
2026-06-27 14:18:15 +05:30
Lok3sh-Kumar 8e0f8cd46a refactor(open-webui): split chat widget into hooks and components
Break the monolithic chat.tsx into focused hooks (use-attachments,
use-chat-stream, use-voice-recording, use-camera-capture) and
presentational components (chat-components), with shared types and
utils extracted to chat-types/chat-utils.

Review fixes applied on top:
- require auth for the streaming completion (protectedProcedure) so
  anonymous board viewers cannot spend the owner's LLM credits
- rename image-only state to images and the picker query options object
  to pickerQueryOptions for clarity
- extract AttachPopover, clearPending(), composerEmpty and
  releaseAudioResources() to remove duplication
2026-06-27 14:17:59 +05:30
Lok3sh-Kumar 84275bd19a fix(open-webui): show knowledge-base documents by default
Expand each knowledge base's file list by default in the picker (with a
"no files" hint when empty) so a base's documents are visible without an
extra click and can be selected individually.
2026-06-27 14:17:27 +05:30
Lok3sh-Kumar b232cc5bb0 feat(open-webui): redesign voice recording with live waveform
Move the mic to the right of the input (shown when the box is empty).
While recording, the input becomes a recording bar with a cancel button,
a live microphone waveform (Web Audio analyser), an elapsed timer and a
confirm button that stops and transcribes into the message box.
2026-06-27 14:17:10 +05:30
Lok3sh-Kumar 18afb866b7 fix(open-webui): list knowledge-base files for the sub-document picker
The knowledge detail endpoint returns `files: null` on recent Open WebUI
versions, so the expandable picker showed nothing to select. List the
files endpoint and filter by the base's collection id instead; each file
grounds via its own `file-{id}` collection.
2026-06-27 14:16:57 +05:30
Lok3sh-Kumar 46454cc0f0 feat(open-webui): voice messages, fix camera capture and full notes
- Voice messages: record from the microphone and transcribe to the input
  via Open WebUI's /api/v1/audio/transcriptions (mic button in composer).
- Camera capture: attach the stream via a ref callback so the preview is
  no longer a black frame (the ref could be read before the video mounted).
- Notes: fetch the full note body on attach. The list endpoint truncates
  content to 1000 chars, so attached notes were cut off; getNote returns
  the complete markdown.
2026-06-27 14:16:42 +05:30
Lok3sh-Kumar d74728c28d feat(open-webui): animated typing indicator while awaiting reply
Replace the static "…" placeholder with a three-dot wave animation shown
in the assistant bubble until the first token streams in. Falls back to a
fade for users who prefer reduced motion.
2026-06-27 14:16:29 +05:30
Lok3sh-Kumar 78bd66e803 feat(open-webui): full attachment menu with all source types
Replace the single attach popover with an Open WebUI-style multi-level
menu covering every attachment source:

- Upload Files: images stay inline (vision); other files are uploaded and
  grounded via retrieval (`file-{id}` collection).
- Capture: take a photo from the webcam as an inline image.
- Attach Webpage: ingest a URL (existing).
- Attach Files: pick files already stored in Open WebUI.
- Attach Notes: inject the contents of a note as context.
- Attach Knowledge: nested picker with checkboxes, expandable to a base's
  files (replaces the flat checkbox list).
- Reference Chats: bring a previous conversation in as context.

Knowledge bases, web pages and files are grounded by retrieval; notes and
referenced chats are injected verbatim as context blocks. Adds the
supporting integration methods (list files/notes, knowledge files, upload
file), request handlers and tRPC procedures.
2026-06-27 14:16:13 +05:30
Lok3sh-Kumar 800caf6e5e fix(open-webui): tidy model selector label and width
Trim the ":latest" tag from model labels, widen the selector and use
ellipsis truncation so long model names no longer clip mid-word.
2026-06-27 14:15:50 +05:30
Lok3sh-Kumar bc26231d13 fix(open-webui): always show Knowledge section in attach menu
The Knowledge picker was hidden entirely when the account had no knowledge
bases, making the feature look missing. Always render the section with an
empty-state hint so it stays discoverable.
2026-06-27 14:15:31 +05:30
Lok3sh-Kumar f61e7539d7 feat(open-webui): add image, knowledge and webpage attachments with retry
Extend the AI Chat widget with three ways to ground a conversation:

- Images: attach images to a message, sent inline as OpenAI multimodal
  content parts for vision-capable models. A hint warns when the selected
  model likely has no vision support.
- Knowledge bases: pick RAG collections exposed by /api/v1/knowledge/.
- Web pages: ingest a URL via /api/v1/retrieval/process/web.

Open WebUI does not run RAG from the chat-completions `files` parameter,
so attached collections (web pages and knowledge bases) are grounded by
retrieving the most relevant chunks at send time
(/api/v1/retrieval/query/collection) and injecting them as system context.

Also add a Retry action to re-run the last turn after a model failure, and
handle the paginated `{ items, total }` shape of the knowledge endpoint on
newer Open WebUI versions.
2026-06-27 14:15:08 +05:30
Lok3sh-Kumar 005c2e4cd1 feat: add Open WebUI AI chat integration and widget
Adds a new "openWebUi" integration kind (under a new "ai" category) and an
AI Chat widget for chatting with models served by Open WebUI.

- Integration: Bearer API key auth, model list, native chat history CRUD, and
  token-by-token streaming chat completions (SSE).
- Widget: in-input model picker, streaming responses, and an in-widget history
  panel. Reconstructs conversations from Open WebUI's message tree with a flat
  fallback.
- Streaming is delivered over the WebSocket subscription link.
- Adds integration and widget documentation.
2026-06-27 14:12:48 +05:30
137 changed files with 16249 additions and 789 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.70.0
- 1.69.2
- 1.69.0
- 1.68.0
+2
View File
@@ -1,3 +1,5 @@
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
### Bug Fixes
+7
View File
@@ -135,6 +135,13 @@ packages/integrations/src/overseerr @manuel-rw
apps/docs/docs/integrations/paperless-ngx @ajnart
packages/integrations/src/paperless-ngx @ajnart
# PatchMon - introduced in https://github.com/homarr-labs/homarr/pull/6119 by @mrjoshuap
apps/docs/docs/integrations/patchmon @mrjoshuap
packages/integrations/src/patchmon @mrjoshuap
packages/widgets/src/patchmon @mrjoshuap
packages/api/src/router/widgets/patchmon.ts @mrjoshuap
packages/request-handler/src/patchmon.ts @mrjoshuap
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
apps/docs/docs/integrations/peanut @afonsojramos
packages/integrations/src/peanut @afonsojramos
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Version: 8.21.0](https://img.shields.io/badge/Version-8.21.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
![AppVersion: v1.69.2](https://img.shields.io/badge/AppVersion-v1.69.2-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.60.0"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.69.2"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
@@ -0,0 +1,39 @@
---
title: "Open WebUI"
description: "Self-hosted AI chat interface for local and remote LLM providers"
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 { openWebUiIntegration } from ".";
import { openWebUiWidget } from "../../widgets/open-webui";
<IntegrationHeader integration={openWebUiIntegration} categories={["AI", "Chat"]} />
[Open WebUI](https://openwebui.com/) is a self-hosted AI chat interface that aggregates multiple LLM providers (Ollama, OpenAI-compatible APIs, and more) behind a single API. Homarr connects to it so you can chat with your models directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: openWebUiWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your Open WebUI instance, for example `http://localhost:8080` (Open WebUI's default port). Homarr calls the Open WebUI API under `/api` automatically.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: ["apiKey"],
steps: [],
},
]}
/>
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const openWebUiIntegration: IntegrationDefinition = {
name: "Open WebUI",
description: "Self-hosted AI chat interface for local and remote LLM providers",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
path: "../../integrations/open-webui",
};
@@ -0,0 +1,43 @@
---
title: 'PatchMon'
description: "PatchMon is a patch management and monitoring solution for Linux hosts."
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 { patchmonIntegration } from '.';
import { patchmonWidget } from '@site/docs/widgets/patchmon';
<IntegrationHeader
integration={patchmonIntegration}
categories={['Health Monitoring']}
/>
PatchMon provides host and package update statistics including total hosts, hosts needing updates, and security update counts.
The PatchMon widget requires users to be **signed in to Homarr**; see the [PatchMon widget documentation](/docs/widgets/patchmon#authentication) for details on authentication and public boards.
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: patchmonWidget },
]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['patchmonApiKey', 'patchmonApiSecret'],
steps: [
'Open your PatchMon instance',
'Navigate to Settings and create a new GetHomepage API key',
'Copy the API key and secret from the success dialog',
'Paste them into Homarr — Basic authentication is handled automatically',
]
}]} />
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const patchmonIntegration: IntegrationDefinition = {
name: "PatchMon",
description:
"PatchMon is a patch management and monitoring solution for Linux hosts, tracking package updates and security patches across your infrastructure.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/patchmon.svg",
path: "../../integrations/patchmon",
};
+1
View File
@@ -141,6 +141,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
| `healthMonitoring` | Resource usage stats | GET |
| `patchmon` | Host patch and security update statistics | GET |
| `mediaRequests` | Approve/decline media requests | GET + POST |
| `mediaServer` | Media server sessions and stats | GET |
| `smartHome` | Execute automations, device states | GET + POST |
@@ -18,7 +18,9 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Screenshots
@@ -1,24 +1,21 @@
---
title: 'Beszel System Stats'
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
title: "Beszel System Stats"
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
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 { beszelSystemStatsWidget } from '.';
import { beszelIntegration } from '@site/docs/integrations/beszel';
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 { beszelSystemStatsWidget } from ".";
import { beszelIntegration } from "@site/docs/integrations/beszel";
import beszelSystemStats from './img/beszel-system-stats.png';
import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader
widget={beszelSystemStatsWidget}
categories={['System Monitoring', 'Charts']}
/>
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -26,14 +23,19 @@ This widget displays time-series charts for a selected Beszel system, including
### Supported Integrations
<WidgetIntegrations items={[{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system"
}]} />
<WidgetIntegrations
items={[
{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
@@ -17,7 +17,9 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Supported Integrations
@@ -0,0 +1,56 @@
---
title: "AI Chat"
description: "Chat with AI models through Open WebUI, with model picking and history."
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 { openWebUiWidget } from ".";
import { openWebUiIntegration } from "@site/docs/integrations/open-webui";
<WidgetHeader widget={openWebUiWidget} categories={["AI", "Chat"]} />
The AI Chat widget lets you chat with AI models served by Open WebUI directly from your dashboard. Responses stream in token-by-token, you can pick any model your Open WebUI instance exposes (Ollama, OpenAI, and other configured providers), and you can browse and reopen your Open WebUI chat history. A full attachment menu lets you ground a conversation on images, uploaded or stored files, web pages, knowledge bases, notes, and previous chats.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: openWebUiIntegration,
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={openWebUiWidget.configuration} />
### Features
| Feature | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Model picker** | Lists every model exposed by Open WebUI's `/api/models` endpoint, selectable from the chat input. The first model is selected by default. |
| **Streaming responses** | Assistant replies stream in live as they are generated. |
| **Chat history** | Browse and reopen previous chats. New conversations are saved back to Open WebUI. |
| **Image attachments** | Attach or capture images for vision-capable models (sent inline as multimodal content). A hint warns when the model has no vision support. |
| **File attachments** | Upload new documents or attach files already stored in Open WebUI; their content is retrieved as context. |
| **Web pages** | Attach a URL; Open WebUI ingests the page and the relevant parts are retrieved as context. |
| **Knowledge bases** | Ground replies on any RAG knowledge collection your account owns, with an expandable picker to inspect a base's files. |
| **Notes** | Attach the contents of your Open WebUI notes. |
| **Reference chats** | Bring a previous conversation in as context for the current one. |
| **Voice messages** | Record from your microphone and have Open WebUI transcribe it into the message box (requires STT configured in Open WebUI). |
### Notes
- The widget uses a single Open WebUI integration.
- Conversations are stored in Open WebUI itself, under the account that owns the API key, so they also appear in the Open WebUI app.
- Knowledge bases and attached web pages stay active for the whole conversation until you remove them; images apply only to the message you attach them to.
- Conversation text is saved to Open WebUI history, but attached image data is not persisted.
@@ -0,0 +1,25 @@
import { WidgetDefinition } from "@site/src/types";
import { IconRobot } from "@tabler/icons-react";
export const openWebUiWidget: WidgetDefinition = {
icon: IconRobot,
name: "AI Chat",
description: "Chat with AI models through Open WebUI, with model picking and history.",
path: "../../widgets/open-webui",
configuration: {
items: [
{
name: "System prompt",
description: "Optional instruction prepended to every conversation.",
values: { type: "string" },
defaultValue: "empty",
},
{
name: "Show chat history",
description: "Show a button to browse and reopen previous Open WebUI chats.",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+89
View File
@@ -0,0 +1,89 @@
---
title: 'PatchMon'
description: 'Displays host patch statistics from PatchMon including update and security counts.'
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 { patchmonWidget } from '.';
import { patchmonIntegration } from '@site/docs/integrations/patchmon';
<WidgetHeader
widget={patchmonWidget}
categories={['Health Monitoring']}
/>
This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning.
PatchMon does not render a built-in widget title. To add a label on your board, open **Edit item → Advanced options → Title** — the same optional overlay badge used by other Homarr widgets.
### Authentication
:::note
This widget requires a **signed-in Homarr session**. Anonymous visitors cannot load PatchMon data, even when the board is [public](/docs/management/boards#access-control). Placing this widget on a public board does not expose patch or security statistics to guests.
Stat visibility toggles in the widget settings control **display only**. Authenticated users with integration query access still receive the full API payload regardless of which stats are hidden in the UI.
:::
### Compliance hero
When **Show compliance hero** is enabled (default), the widget displays a ring showing the percentage of hosts that are fully up to date. The ring color follows the same threshold rules as the "Up-to-Date Hosts" stat. When the widget is **2 or more columns wide**, the hero also shows the "Patch Compliance" label and exact host count (e.g. "27 / 42") beside the ring.
In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a ring-only view so useful compliance data remains visible in tight spaces. If the compliance hero is disabled, compact widgets fall back to the next selected content: OS distribution when enabled, then the first enabled stat.
### Threshold colors
When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity:
| Stat | Green | Yellow | Red |
|------|-------|--------|-----|
| Needs Updates | 0 | 1–4 | 5+ |
| Security Updates | 0 | 1–9 | 10+ |
| Hosts with Security Updates | 0 | — | 1+ |
| Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
| Outdated Packages | 0 | 1–49 | 50+ |
Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts.
### OS distribution
When **Show OS distribution** is enabled, the widget shows a breakdown of monitored hosts by operating system. Two display modes are available:
- **Horizontal bars** (default) — a scrollable list of colored progress bars showing each OS's share
- **Donut chart** — a compact donut chart visualization that scales down in narrow layouts
The OS distribution limit defaults to **All**. A widget size of **2×3** or larger is recommended when using this section alongside the default stats, but compact 1×1 and 2×1 layouts can still show multiple OS rows when OS distribution is the primary selected content. Wider donut layouts can also show a compact legend beside the chart.
### Responsive sizing
The widget adapts its layout based on the available space:
| Widget size | Layout |
|-------------|--------|
| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats |
| **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero |
| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer |
| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
### Last updated
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
### Supported Integrations
<WidgetIntegrations items={[{
integration: patchmonIntegration,
note: "Displays host patch and security update statistics"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={patchmonWidget.configuration} />
+200
View File
@@ -0,0 +1,200 @@
import { WidgetDefinition } from "@site/src/types";
import { IconShieldCheck } from "@tabler/icons-react";
export const patchmonWidget: WidgetDefinition = {
icon: IconShieldCheck,
name: "PatchMon",
description:
"Displays host patch statistics including total hosts, hosts needing updates, and security update counts.",
path: "../../widgets/patchmon",
configuration: {
items: [
{
name: "Show compliance hero",
description: "Display a compliance ring showing the percentage of up-to-date hosts",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show total hosts",
description: "Displays the total number of monitored hosts",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show hosts needing updates",
description: "Displays hosts with at least one outdated package",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show security updates",
description: "Displays the total number of security updates available",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show up-to-date hosts",
description: "Displays hosts with zero outdated packages",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show hosts with security updates",
description: "Displays hosts requiring security patches",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show recent updates (24h)",
description: "Displays the number of updates applied in the last 24 hours",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show outdated packages",
description: "Displays the total number of outdated packages across all hosts",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Show repositories",
description: "Displays the number of active repositories being monitored",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Enable threshold colors",
description: "Colors stat values green, yellow, or red based on built-in severity presets",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Advanced thresholds",
description: "Customize warning and critical thresholds per stat (absolute count or % of total hosts)",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Needs updates threshold mode",
description:
"Compare the needs-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Needs updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Needs updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "5",
},
{
name: "Security updates threshold mode",
description:
"Compare the security-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Security updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Security updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "10",
},
{
name: "Hosts with security updates threshold mode",
description:
"Compare the host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Hosts with security updates warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Hosts with security updates critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Up-to-date hosts threshold mode",
description:
"Compare the up-to-date host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Percent of total hosts",
},
{
name: "Up-to-date hosts warning threshold",
description: "Enter yellow when the value is at or above this threshold but below full coverage",
values: "Integer ≥ 0",
defaultValue: "90",
},
{
name: "Up-to-date hosts critical threshold",
description: "Enter red when the value falls below this threshold",
values: "Integer ≥ 0",
defaultValue: "90",
},
{
name: "Outdated packages threshold mode",
description:
"Compare the outdated-packages count as an absolute value or percentage of total hosts. Shown when threshold colors, advanced thresholds, and Show outdated packages are enabled.",
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
defaultValue: "Absolute count",
},
{
name: "Outdated packages warning threshold",
description: "Enter yellow when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "1",
},
{
name: "Outdated packages critical threshold",
description: "Enter red when the value reaches this threshold",
values: "Integer ≥ 0",
defaultValue: "50",
},
{
name: "Show OS distribution",
description: "Displays a horizontal bar breakdown of monitored hosts by operating system",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "OS display mode",
description:
"Choose how to display the operating system distribution. Shown when Show OS distribution is enabled.",
values: { type: "select", options: ["Horizontal bars", "Donut chart"] },
defaultValue: "Horizontal bars",
},
{
name: "OS distribution limit",
description: "Maximum number of operating systems to display in the distribution section",
values: { type: "select", options: ["Top 3", "Top 5", "Top 10", "All"] },
defaultValue: "All",
},
{
name: "Show OS version",
description: "Append the OS version to each distribution label when available",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -47,6 +47,16 @@ const secretKinds = {
description: "The Secret part of the API Key for authentication.",
icon: IconPassword,
},
patchmonApiKey: {
name: "API Key",
description: "The API key from PatchMon's GetHomepage integration.",
icon: IconKey,
},
patchmonApiSecret: {
name: "API Secret",
description: "The API secret from PatchMon's GetHomepage integration.",
icon: IconPassword,
},
githubAppId: { name: "App ID", description: "The ID of the GitHub App", icon: IconCode },
githubInstallationId: {
name: "Installation ID",
@@ -0,0 +1,11 @@
"use client";
import dynamic from "next/dynamic";
const OpenWebUiChat = dynamic(() => import("~/components/open-webui-chat").then((mod) => mod.OpenWebUiChat), {
ssr: false,
});
export const OpenWebUiChatProvider = () => {
return <OpenWebUiChat />;
};
@@ -10,6 +10,7 @@ import {
createWSClient,
httpBatchStreamLink,
httpLink,
httpSubscriptionLink,
isNonJsonSerializable,
loggerLink,
splitLink,
@@ -101,9 +102,17 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}),
splitLink({
condition: ({ type }) => type === "subscription",
true: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
true: splitLink({
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
true: httpSubscriptionLink({
url: getTrpcUrl(),
transformer: superjson,
eventSourceOptions: { withCredentials: true },
}),
false: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
}),
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
@@ -22,6 +22,7 @@ import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
@@ -40,7 +41,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -41,15 +41,12 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = () => {
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
// edit/save/settings UI that would then fail server-side.
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
if (!hasChangeAccess || isLoading) {
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
+2
View File
@@ -32,6 +32,7 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { OpenWebUiChatProvider } from "./_client-providers/open-webui-chat";
import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine";
import { AuthProvider } from "./_client-providers/session";
@@ -147,6 +148,7 @@ export default async function Layout(props: {
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
<OpenWebUiChatProvider />
{props.children}
</StackedProvider>
</body>
@@ -26,6 +26,8 @@ export const integrationSecretIcons = {
url: IconLink,
opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword,
patchmonApiKey: IconKey,
patchmonApiSecret: IconPassword,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
@@ -4,7 +4,7 @@ import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
@@ -49,7 +49,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
const widgetQueryKey = useMemo(
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
[currentDefinition, item.kind],
);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
@@ -276,7 +279,7 @@ const WidgetCacheAge = ({
isFetching,
}: {
queryClient: QueryClient;
queryKey: unknown[];
queryKey: QueryKey;
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
@@ -295,8 +298,8 @@ const WidgetCacheAge = ({
.filter(Boolean);
if (timestamps.length === 0) return null;
const oldest = Math.min(...timestamps);
const seconds = Math.floor((Date.now() - oldest) / 1000);
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
@@ -105,7 +105,7 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
content: (
<TourStepContent
description={t("welcome.description")}
documentationHref={createDocumentationLink("/docs/category/management")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
@@ -0,0 +1,108 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Drawer } from "@mantine/core";
import { useHotkeys, useMediaQuery } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { openWebUiChatEvent } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { Chat } from "@homarr/widgets/_open-webui/chat";
/**
* App-wide launcher for the Open WebUI chat. Renders a right-side panel that can
* be opened from the header user menu, the {@link hotkeys.toggleAiChat} keybind,
* or the spotlight "ask AI" mode (via the {@link openWebUiChatEvent} window
* event). Renders nothing unless the signed-in user owns an Open WebUI
* integration.
*/
export const OpenWebUiChat = () => {
const t = useI18n();
const { data: session } = useSession();
const isMobile = useMediaQuery("(max-width: 767px)");
const [opened, setOpened] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>(undefined);
// Bumped on every open so the chat remounts and a fresh initial query is sent.
const [openNonce, setOpenNonce] = useState(0);
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const myOpenWebUiIntegration = useMemo(() => {
if (!session?.user?.id) return null;
return integrations.find(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.user.id,
);
}, [integrations, session?.user?.id]);
const open = useCallback((query?: string) => {
setInitialQuery(query?.trim() ? query.trim() : undefined);
setOpenNonce((nonce) => nonce + 1);
setOpened(true);
}, []);
const toggle = useCallback(() => {
setOpened((previous) => {
if (previous) return false;
setInitialQuery(undefined);
setOpenNonce((nonce) => nonce + 1);
return true;
});
}, []);
// Open (with an optional query) when the spotlight "ask AI" mode dispatches.
useEffect(() => {
const handler = (event: Event) => {
if (!myOpenWebUiIntegration) return;
open((event as CustomEvent<{ query?: string }>).detail?.query);
};
window.addEventListener(openWebUiChatEvent, handler);
return () => window.removeEventListener(openWebUiChatEvent, handler);
}, [open, myOpenWebUiIntegration]);
useHotkeys([[hotkeys.toggleAiChat, () => myOpenWebUiIntegration && toggle()]]);
if (!myOpenWebUiIntegration) return null;
return (
<Drawer
opened={opened}
onClose={() => setOpened(false)}
position="right"
size={isMobile ? "100%" : 480}
title={t("widget.openWebUi.name")}
keepMounted={false}
styles={{
body: {
height: "calc(100dvh - 60px)",
display: "flex",
flexDirection: "column",
padding: 0,
},
}}
>
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Chat
key={openNonce}
options={{ systemPrompt: "", showHistory: true }}
integrationIds={[myOpenWebUiIntegration.id]}
itemId={undefined}
boardId={undefined}
isEditMode={false}
setOptions={() => {
// No-op: options are fixed for the launcher chat.
}}
width={480}
height={600}
initialQuery={initialQuery}
/>
</Box>
</Drawer>
);
};
@@ -11,14 +11,17 @@ import {
IconHome,
IconLogin,
IconLogout,
IconRobot,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { signOut, useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { openOpenWebUiChat } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -46,6 +49,15 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session.data?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const hasOpenWebUiIntegration = integrations.some(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.data?.user?.id,
);
const handleSignout = useCallback(async () => {
await signOut({
redirect: false,
@@ -96,6 +108,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
{t("docker")}
</Menu.Item>
)}
{hasOpenWebUiIntegration && (
<Menu.Item leftSection={<IconRobot size="1rem" />} onClick={() => openOpenWebUiChat()}>
{t("aiChat")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
+1
View File
@@ -4,6 +4,7 @@ export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
},
experimental__runtimeEnv: process.env,
});
+40 -26
View File
@@ -95,20 +95,27 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/bazarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg" alt="Bazarr" width="90" height="90" />
<br/>
<p align="center">Bazarr</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/beszel" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg" alt="Beszel" width="90" height="90" />
<br/>
<p align="center">Beszel</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
<br/>
<p align="center">Coolify</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
<br/>
@@ -149,15 +156,15 @@
<br/>
<p align="center">Gotify</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/home-assistant" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
<br/>
<p align="center">Home<br/>Assistant</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
<br/>
@@ -198,15 +205,15 @@
<br/>
<p align="center">Navidrome</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/nextcloud" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg" alt="Nextcloud" width="90" height="90" />
<br/>
<p align="center">Nextcloud</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/ntfy" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg" alt="ntfy" width="90" height="90" />
<br/>
@@ -247,6 +254,13 @@
<br/>
<p align="center">Paperless-ngx</p>
</a>
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/patchmon" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg" alt="PatchMon" width="90" height="90" />
<br/>
<p align="center">PatchMon</p>
</a>
</td>
<td align="center">
<a href="null" target="_blank" rel="noreferrer noopener">
@@ -254,8 +268,8 @@
<br/>
<p align="center">PeaNUT</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
<br/>
@@ -289,8 +303,8 @@
<br/>
<p align="center">qBittorrent</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/radarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" alt="Radarr" width="90" height="90" />
<br/>
@@ -303,8 +317,8 @@
<br/>
<p align="center">Readarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/>
@@ -338,8 +352,8 @@
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/synology" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg" alt="Synology DiskStation" width="90" height="90" />
<br/>
@@ -352,8 +366,8 @@
<br/>
<p align="center">Tdarr</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/technitium-dns" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg" alt="Technitium DNS" width="90" height="90" />
<br/>
@@ -387,8 +401,8 @@
<br/>
<p align="center">TrueNAS</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/umami" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg" alt="Umami" width="90" height="90" />
<br/>
@@ -401,8 +415,8 @@
<br/>
<p align="center">Unifi<br/>Controller</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/unraid" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg" alt="Unraid" width="90" height="90" />
<br/>
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.69.2",
"version": "1.70.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -43,7 +43,6 @@
"@semantic-release/release-notes-generator": "catalog:",
"@testcontainers/redis": "catalog:",
"@turbo/gen": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
+2
View File
@@ -18,6 +18,7 @@ import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { patchmonRouter } from "./router/widgets/patchmon";
import { smartHomeRouter } from "./router/widgets/smart-home";
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
@@ -39,6 +40,7 @@ export const mcpRouter = createTRPCRouter({
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
patchmon: patchmonRouter,
smartHome: smartHomeRouter,
widgetSecrets: widgetSecretsRouter,
});
+1 -1
View File
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
@@ -99,6 +99,7 @@ export const integrationRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
url: integration.url,
creatorId: integration.creatorId,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
@@ -344,23 +345,66 @@ export const integrationRouter = createTRPCRouter({
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
});
const userId = ctx.session.user.id;
const secretValues = input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
}));
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
// Create the integration, its creator permission and secrets atomically so
// a failure part-way through can't leave a committed integration the
// creator can neither use nor manage.
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (transaction) => {
await transaction.insert(schema.integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
});
// Auto-grant the creator "full" permission so they can immediately
// use and manage the integration they just created.
await transaction.insert(schema.integrationUserPermissions).values({
integrationId,
userId,
permission: "full",
});
if (secretValues.length >= 1) {
await transaction.insert(schema.integrationSecrets).values(secretValues);
}
});
},
handleSync: (db) => {
db.transaction((transaction) => {
transaction
.insert(integrations)
.values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
})
.run();
transaction
.insert(integrationUserPermissions)
.values({
integrationId,
userId,
permission: "full",
})
.run();
if (secretValues.length >= 1) {
transaction.insert(integrationSecrets).values(secretValues).run();
}
});
},
});
logger.info("Created integration", {
id: integrationId,
@@ -4,6 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { and, eq } from "@homarr/db";
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
@@ -301,6 +302,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -317,10 +322,23 @@ describe("create should create a new integration", () => {
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbPermission = await db.query.integrationUserPermissions.findFirst({
where: and(
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
eq(integrationUserPermissions.userId, defaultUserId),
),
});
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
// Creator should be auto-granted "full" permission
expect(dbPermission).toBeDefined();
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
expect(dbPermission!.userId).toBe(defaultUserId);
expect(dbPermission!.permission).toBe("full");
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -336,6 +354,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
@@ -357,6 +379,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -374,6 +397,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -405,6 +432,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbIntegration!.app!.name).toBe(input.app.name);
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
expect(dbIntegration!.app!.href).toBe(input.app.href);
@@ -431,6 +459,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -455,6 +487,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -0,0 +1,200 @@
import { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { boards, integrationItems, integrations, integrationUserPermissions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { PatchMonStats } from "@homarr/integrations";
import { patchmonRouter } from "../../widgets/patchmon";
const samplePatchMonStats = {
totalHosts: 42,
hostsNeedingUpdates: 15,
securityUpdates: 23,
upToDateHosts: 27,
hostsWithSecurityUpdates: 8,
recentUpdates24h: 34,
totalOutdatedPackages: 156,
totalRepos: 12,
lastUpdated: "2025-10-11T12:34:56.789Z",
osDistribution: [],
} satisfies PatchMonStats;
vi.mock("@homarr/request-handler/patchmon", () => ({
patchmonStatsRequestHandler: {
handler: () => ({
getDataAsync: async () => ({
data: samplePatchMonStats,
timestamp: new Date(),
}),
}),
},
}));
const createSession = (userId: string): Session => ({
user: {
id: userId,
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
});
const createCaller = (db: Database, session: Session | null) =>
patchmonRouter.createCaller({
db,
deviceType: undefined,
session,
});
const createPatchMonIntegrationOnBoardAsync = async (
db: Database,
options: {
isPublic: boolean;
boardCreatorId: string;
},
) => {
const integrationId = createId();
const boardId = createId();
const itemId = createId();
await db.insert(integrations).values({
id: integrationId,
kind: "patchmon",
name: "PatchMon",
url: "https://patchmon.example.com",
});
await db.insert(boards).values({
id: boardId,
name: "patchmon-board",
creatorId: options.boardCreatorId,
isPublic: options.isPublic,
});
await db.insert(items).values({
id: itemId,
kind: "patchmon",
boardId,
options: "{}",
});
await db.insert(integrationItems).values({
integrationId,
itemId,
});
return { integrationId, boardId, itemId };
};
describe("patchmonRouter.getStats access control", () => {
test("should throw UNAUTHORIZED for unauthenticated users even when integration is on a public board", async () => {
const db = createDb();
const otherUserId = createId();
await db.insert(users).values({ id: otherUserId });
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
isPublic: true,
boardCreatorId: otherUserId,
});
const caller = createCaller(db, null);
await expect(caller.getStats({ integrationId })).rejects.toThrow(
new TRPCError({ code: "UNAUTHORIZED" }),
);
});
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
const db = createDb();
const boardOwnerId = createId();
const sessionUserId = createId();
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
isPublic: false,
boardCreatorId: boardOwnerId,
});
const caller = createCaller(db, createSession(sessionUserId));
await expect(caller.getStats({ integrationId })).rejects.toThrow(
new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to query at least one of the specified integration",
}),
);
});
test("should return stats for authenticated users when integration is on a public board", async () => {
const db = createDb();
const boardOwnerId = createId();
const sessionUserId = createId();
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
isPublic: true,
boardCreatorId: boardOwnerId,
});
const caller = createCaller(db, createSession(sessionUserId));
const result = await caller.getStats({ integrationId });
expect(result).toStrictEqual(samplePatchMonStats);
});
test("should return stats for authenticated users with integration use permission and no board placement", async () => {
const db = createDb();
const sessionUserId = createId();
const integrationId = createId();
await db.insert(users).values({ id: sessionUserId });
await db.insert(integrations).values({
id: integrationId,
kind: "patchmon",
name: "PatchMon",
url: "https://patchmon.example.com",
});
await db.insert(integrationUserPermissions).values({
integrationId,
userId: sessionUserId,
permission: "use",
});
const caller = createCaller(db, createSession(sessionUserId));
const result = await caller.getStats({ integrationId });
expect(result).toStrictEqual(samplePatchMonStats);
});
test("should throw NOT_FOUND when integration id does not exist", async () => {
const db = createDb();
const sessionUserId = createId();
await db.insert(users).values({ id: sessionUserId });
const caller = createCaller(db, createSession(sessionUserId));
await expect(caller.getStats({ integrationId: createId() })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
test("should throw NOT_FOUND when integration is not a PatchMon integration", async () => {
const db = createDb();
const sessionUserId = createId();
const integrationId = createId();
await db.insert(users).values({ id: sessionUserId });
await db.insert(integrations).values({
id: integrationId,
kind: "sonarr",
name: "Sonarr",
url: "https://sonarr.example.com",
});
await db.insert(integrationUserPermissions).values({
integrationId,
userId: sessionUserId,
permission: "use",
});
const caller = createCaller(db, createSession(sessionUserId));
await expect(caller.getStats({ integrationId })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
});
+39 -1
View File
@@ -207,6 +207,13 @@ export const beszelRouter = createTRPCRouter({
return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController();
let isActive = true;
let emittedEventCount = 0;
logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
});
void (async () => {
const integration = ctx.integrations[0];
@@ -223,7 +230,19 @@ export const beszelRouter = createTRPCRouter({
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
if (isActive) emit.next(event);
if (!isActive) return;
emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
eventType: event.type,
emittedEventCount,
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
});
}
emit.next(event);
},
controller.signal,
);
@@ -237,6 +256,13 @@ export const beszelRouter = createTRPCRouter({
}
} catch (error) {
if (isActive) {
logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
});
emit.error(
error instanceof TRPCError
? error
@@ -249,12 +275,24 @@ export const beszelRouter = createTRPCRouter({
}
if (isActive) {
logger.debug("Beszel realtime subscription completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
});
emit.complete();
}
})();
return () => {
isActive = false;
logger.debug("Beszel realtime subscription cancelled", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
emittedEventCount,
});
controller.abort();
};
});
+2
View File
@@ -9,6 +9,7 @@ export const widgetRouter = createTRPCRouter({
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)),
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
@@ -42,5 +43,6 @@ export const widgetRouter = createTRPCRouter({
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
@@ -21,7 +21,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
...data,
@@ -0,0 +1,379 @@
import { observable } from "@trpc/server/observable";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import {
openWebUiChatRequestHandler,
openWebUiChatsRequestHandler,
openWebUiFilesRequestHandler,
openWebUiKnowledgeFilesRequestHandler,
openWebUiKnowledgeRequestHandler,
openWebUiModelsRequestHandler,
openWebUiNoteRequestHandler,
openWebUiNotesRequestHandler,
} from "@homarr/request-handler/open-webui";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "openWebUiRouter" });
/**
* Open WebUI integrations hold a personal API key that grants access to a
* single user's data (chat history, files, notes, knowledge bases). To prevent
* other Homarr users from reading that data, every procedure that touches the
* integration verifies that the caller is the integration's creator.
*
* The check is intentionally placed **after** the integration middleware (which
* already validates the caller's permission level) so the error message does
* not leak whether the integration exists.
*/
const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => {
// Existing integrations that predate the creatorId column have a null
// creatorId; we skip the check for backward compatibility.
if (integration.creatorId && integration.creatorId !== userId) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" });
}
};
// Hosts that should never be fetched server-side. processWeb is already
// creator-gated, but we still refuse loopback and the cloud metadata endpoint so
// a stray URL can't turn Open WebUI's fetcher into an SSRF vector. Private LAN
// ranges stay allowed on purpose (self-hosters routinely ingest internal wikis).
const isBlockedWebHost = (hostname: string): boolean => {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "0.0.0.0" ||
host === "::" ||
host === "::1" ||
host.startsWith("127.") ||
host === "169.254.169.254" || // AWS/GCP/Azure instance metadata
host === "metadata.google.internal"
);
};
// A user-provided URL for web-page ingestion: must be http(s) and not target a
// loopback/metadata host.
const webUrlSchema = z
.string()
.url()
.refine((value) => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !isBlockedWebHost(parsed.hostname);
}, "URL must be an http(s) address and must not point at a loopback or metadata host");
// Roughly 18 MB of raw bytes once base64-decoded (base64 inflates by ~4/3).
// Bounds the upload/transcription payloads so a huge request can't pressure
// memory here or upstream before we even start decoding.
const MAX_BASE64_LENGTH = 25_000_000;
const boundedBase64Schema = z.string().min(1).max(MAX_BASE64_LENGTH);
const uploadFilenameSchema = z.string().min(1).max(255);
const contentTypeSchema = z.string().min(1).max(255);
// Tag persisted conversations with the owning Homarr user so they can be found
// back in Open WebUI even when an integration/account is shared between users.
const homarrUserTag = (userId: string) => `homarr-user:${userId}`;
const messageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
});
// Outbound completion messages may carry multimodal parts (text + images),
// so users can attach images to a message.
const contentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
const completionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.union([z.string(), z.array(contentPartSchema)]),
});
const chatPayloadSchema = z.object({
title: z.string(),
models: z.array(z.string()),
messages: z.array(messageSchema),
});
type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string };
type CompletionMessage = z.infer<typeof completionMessageSchema>;
// Extract the plain text of a message, flattening multimodal content parts.
const messageText = (message: CompletionMessage): string =>
typeof message.content === "string"
? message.content
: message.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" ");
// Number of chunks to retrieve per collection when grounding a reply.
const RETRIEVAL_K = 4;
// Instruction prepended to the retrieved context so the model knows how to use
// it. Model-facing, so intentionally not localized.
const RETRIEVAL_INSTRUCTION =
"Use the following context from the user's attached sources (web pages, knowledge bases, " +
"files, notes and chats) to answer. If the answer is not in the context, say so.";
// Retrieve relevant chunks from the attached collections and prepend them as a
// system context message so the model can ground its reply on them.
const groundMessagesAsync = async (
client: { queryCollectionsAsync: (names: string[], query: string, k?: number) => Promise<string[]> },
messages: CompletionMessage[],
collections: string[],
contextTexts: string[],
): Promise<CompletionMessage[]> => {
if (collections.length === 0 && contextTexts.length === 0) return messages;
// Use the most recent user message that has text. The latest turn can be
// image-only (no text), in which case we fall back to the prior question so
// attached collections still get a meaningful retrieval query.
const query =
messages
.filter((message) => message.role === "user")
.map(messageText)
.findLast((text) => text.trim() !== "") ?? "";
let documents: string[] = [];
if (collections.length > 0) {
try {
documents = await client.queryCollectionsAsync(collections, query, RETRIEVAL_K);
} catch (error) {
logger.warn("Open WebUI context retrieval failed; continuing without it", { error });
}
}
const blocks = [...documents, ...contextTexts].filter((block) => block.trim() !== "");
if (blocks.length === 0) return messages;
const contextMessage: CompletionMessage = {
role: "system",
content: `${RETRIEVAL_INSTRUCTION}\n\n${blocks.join("\n\n")}`,
};
return [contextMessage, ...messages];
};
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Chat history exposes private user data, so we require "interact" access
// (not just "query") and verify the caller is the integration's creator.
getChats: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Knowledge bases, files and notes expose private source content/metadata, so
// gate them like the interactive chat paths (protected + "interact" access,
// creator-only) instead of leaving them reachable by anonymous board viewers.
getKnowledge: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getFiles: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNotes: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNote: protectedProcedure
.input(z.object({ noteId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getDataAsync();
return data;
}),
getKnowledgeFiles: protectedProcedure
.input(z.object({ knowledgeId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
knowledgeId: input.knowledgeId,
});
const { data } = await handler.getDataAsync();
return data;
}),
// Ingest a web page so its content can ground later messages. Returns the
// collection to retrieve from plus a display title.
processWeb: protectedProcedure
.input(z.object({ url: webUrlSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.processWebAsync(input.url);
}),
// Upload a file (e.g. a document) so it can be retrieved from later. Returns
// the file id; its vector collection is `file-{id}`.
uploadFile: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType);
}),
// Transcribe recorded audio to text for voice messages.
transcribe: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType);
return { text };
}),
// Full chat messages contain private user data; restrict to the creator.
getChat: protectedProcedure
.input(z.object({ chatId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
const { data } = await handler.getDataAsync();
return data;
}),
createChat: protectedProcedure
.input(chatPayloadSchema)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.createChatAsync({
title: input.title,
models: input.models,
messages: input.messages,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
return chat;
}),
updateChat: protectedProcedure
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.updateChatAsync(input.chatId, {
...input.chat,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
openWebUiChatRequestHandler.invalidateCache();
return chat;
}),
// Streaming chat completion. Delivered over the WebSocket subscription link so
// tokens can be emitted as they arrive. A completion spends the owner's upstream
// LLM credits, so it must not be reachable by anonymous viewers of a public
// board: protectedProcedure requires a session (the WS context resolves it from
// the session-token cookie) and the "interact" middleware checks integration
// access, matching processWeb/uploadFile/transcribe above.
sendMessage: protectedProcedure
.input(
z.object({
model: z.string(),
messages: z.array(completionMessageSchema),
// Collections to ground the reply on (web pages, knowledge bases, files).
// We retrieve relevant chunks and inject them before streaming.
collections: z.array(z.string()).optional(),
// Verbatim context blocks (notes, referenced chats) injected as-is.
contextTexts: z.array(z.string()).optional(),
}),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.subscription(({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
return observable<StreamEvent>((emit) => {
const controller = new AbortController();
void (async () => {
try {
const client = await createIntegrationAsync(ctx.integration);
const messages = await groundMessagesAsync(
client,
input.messages,
input.collections ?? [],
input.contextTexts ?? [],
);
await client.streamChatCompletionAsync(
{ model: input.model, messages },
(delta) => emit.next({ type: "delta", content: delta }),
controller.signal,
);
emit.next({ type: "done" });
emit.complete();
} catch (error) {
if (!controller.signal.aborted) {
logger.error("Open WebUI chat completion failed", {
integrationId: ctx.integration.id,
error,
});
emit.next({
type: "error",
message: error instanceof Error ? error.message : "Failed to stream chat completion",
});
}
emit.complete();
}
})();
return () => {
controller.abort();
};
});
}),
});
@@ -0,0 +1,21 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const patchmonRouter = createTRPCRouter({
getStats: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
},
})
.concat(createOneIntegrationMiddleware("query", "patchmon"))
.query(async ({ ctx }) => {
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
return data;
}),
});
+12 -6
View File
@@ -45,7 +45,7 @@ export const releasesRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const tokensByProvider = new Map<string, string>();
let allowedRepoIds: Set<string> | null = null;
const savedRepos = new Map<string, { providerUrl?: string }>();
if (input.itemId) {
try {
@@ -54,9 +54,15 @@ export const releasesRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
const repos = options.repositories as Array<{ id?: string }> | undefined;
const repos = options.repositories as
| Array<{ provider?: string; identifier: string; providerUrl?: string }>
| undefined;
if (repos) {
allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id)));
for (const repo of repos) {
if (repo.provider && repo.identifier) {
savedRepos.set(`${repo.provider}:${repo.identifier}`, { providerUrl: repo.providerUrl });
}
}
}
const secrets = await ctx.db.query.widgetSecrets.findMany({
@@ -78,16 +84,16 @@ export const releasesRouter = createTRPCRouter({
return await Promise.all(
input.repositories.map(async (repository) => {
const repositoryId = repository.id ?? repository.identifier;
const savedRepo = savedRepos.get(`${repository.provider}:${repository.identifier}`);
try {
const useToken = allowedRepoIds === null || allowedRepoIds.has(repositoryId);
const response = await releasesRequestHandler
.handler({
id: repositoryId,
provider: repository.provider,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
providerUrl: repository.providerUrl,
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
providerUrl: savedRepo?.providerUrl ?? repository.providerUrl,
token: savedRepo ? tokensByProvider.get(repository.provider) : undefined,
})
.getDataAsync();
@@ -67,6 +67,8 @@ describe("isPersistableWidgetQueryKey", () => {
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
[[["widget", "beszel", "getSystems"], { type: "query" }], true],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
@@ -11,6 +11,7 @@ interface IntegrationContextProps {
name: string;
url: string;
kind: IntegrationKind;
creatorId: string | null;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
@@ -15,6 +15,7 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
name: true,
url: true,
kind: true,
creatorId: true,
},
with: {
userPermissions: {
@@ -2,7 +2,7 @@ import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls";
import axios from "axios";
import type { RequestInfo, RequestInit, Response } from "undici";
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
@@ -29,11 +29,15 @@ export const createCustomCheckServerIdentity = (
};
};
export const createCertificateAgentAsync = async (override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
}) => {
export const createCertificateAgentAsync = async (
override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
},
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
) => {
return new UndiciHttpAgent({
...agentOptions,
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
@@ -61,14 +65,18 @@ export const createAxiosCertificateInstanceAsync = async (
export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
options?: RequestInit & { timeout?: number },
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise<Response> => {
const agent = await createCertificateAgentAsync(undefined);
const agent = await createCertificateAgentAsync(
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
);
if (options?.timeout) {
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
return await withTimeoutAsync(
async (signal) =>
fetch(url, {
...options,
...fetchOptions,
signal,
dispatcher: agent,
}),
@@ -76,8 +84,9 @@ export const fetchWithTrustedCertificatesAsync = async (
);
}
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
return fetch(url, {
...options,
...fetchOptions,
dispatcher: agent,
});
};
@@ -0,0 +1,2 @@
ALTER TABLE `integration` ADD `creator_id` varchar(64);--> statement-breakpoint
ALTER TABLE `integration` ADD CONSTRAINT `integration_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -295,6 +295,20 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1783786572320,
"tag": "0043_add_integration_creator_id",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE "integration" ADD COLUMN "creator_id" varchar(64);--> statement-breakpoint
ALTER TABLE "integration" ADD CONSTRAINT "integration_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
@@ -57,6 +57,20 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1783786378808,
"tag": "0009_add_integration_creator_id",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE `integration` ADD `creator_id` text REFERENCES user(id) ON DELETE SET NULL;
File diff suppressed because it is too large. Load diff
@@ -281,6 +281,20 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786326756,
"tag": "0041_add_integration_creator_id",
"breakpoints": true
}
]
}
+5
View File
@@ -209,6 +209,7 @@ export const integrations = mysqlTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -688,6 +689,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+5
View File
@@ -208,6 +208,7 @@ export const integrations = pgTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -687,6 +688,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+5
View File
@@ -195,6 +195,7 @@ export const integrations = sqliteTable(
url: text().notNull(),
kind: text().$type<IntegrationKind>().notNull(),
appId: text().references(() => apps.id, { onDelete: "set null" }),
creatorId: text().references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -677,6 +678,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
@@ -31,6 +31,7 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
audiobookshelf: ["audiobookshelf"],
navidrome: ["navidrome"],
paperlessNgx: ["paperless-ngx", "paperless"],
patchmon: ["patchmon", "patch-mon"],
coolify: ["coolify"],
truenas: ["truenas"],
bazarr: ["bazarr"],
@@ -159,6 +159,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/aria2"
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/bazarr"
| "/docs/integrations/beszel"
| "/docs/integrations/coolify"
| "/docs/integrations/dash-dot"
@@ -183,6 +184,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/opnsense"
| "/docs/integrations/overseerr"
| "/docs/integrations/paperless-ngx"
| "/docs/integrations/patchmon"
| "/docs/integrations/peanut"
| "/docs/integrations/pi-hole"
| "/docs/integrations/plex"
@@ -223,6 +225,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/anchor-note"
| "/docs/widgets/app"
| "/docs/widgets/audio-stats"
| "/docs/widgets/bazarr"
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-grid"
| "/docs/widgets/beszel-system-stats"
@@ -242,6 +245,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/immich-album-carousel"
| "/docs/widgets/immich-server-stats"
| "/docs/widgets/indexer-manager"
| "/docs/widgets/media-missing"
| "/docs/widgets/media-releases"
| "/docs/widgets/media-request-list"
| "/docs/widgets/media-request-stats"
@@ -253,6 +257,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/notebook"
| "/docs/widgets/notifications"
| "/docs/widgets/paperless-ngx"
| "/docs/widgets/patchmon"
| "/docs/widgets/releases"
| "/docs/widgets/rss-feed"
| "/docs/widgets/smart-home-entity-state"
@@ -313,6 +318,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/coolify"
| "/docs/integrations/immich"
| "/docs/integrations/paperless-ngx"
| "/docs/integrations/patchmon"
| "/docs/integrations/tracearr"
| "/docs/integrations/speedtest-tracker"
| "/docs/integrations/uptime-kuma"
@@ -323,6 +329,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/traefik"
| "/docs/integrations/open-webui"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/app"
@@ -341,6 +348,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/media-request-list"
| "/docs/widgets/media-request-stats"
| "/docs/widgets/media-transcoding"
| "/docs/widgets/media-missing"
| "/docs/widgets/minecraft-server-status"
| "/docs/widgets/network-controller-summary"
| "/docs/widgets/network-controller-status"
@@ -360,6 +368,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/immich-server-stats"
| "/docs/widgets/immich-album-carousel"
| "/docs/widgets/paperless-ngx"
| "/docs/widgets/patchmon"
| "/docs/widgets/bazarr"
| "/docs/widgets/tracearr"
| "/docs/widgets/speedtest-tracker"
@@ -373,4 +382,5 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api";
| "/docs/widgets/custom-api"
| "/docs/widgets/open-webui";
@@ -42,6 +42,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
coolify: "coolify",
immich: "immich",
paperlessNgx: "paperless-ngx",
patchmon: "patchmon",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
uptimeKuma: "uptime-kuma",
@@ -53,5 +54,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
openWebUi: "open-webui",
mock: null,
};
@@ -39,6 +39,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
"immich-serverStats": "immich-server-stats",
"immich-albumCarousel": "immich-album-carousel",
paperlessNgx: "paperless-ngx",
patchmon: "patchmon",
bazarr: "bazarr",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
@@ -54,4 +55,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
openWebUi: "open-webui",
};
+1
View File
@@ -3,4 +3,5 @@ export const hotkeys = {
toggleColorScheme: "mod+j",
saveNotebook: "mod+s",
openSpotlight: "mod+k",
toggleAiChat: "mod+shift+a",
};
+21
View File
@@ -13,6 +13,8 @@ export const integrationSecretKindObject = {
topic: { isPublic: true, multiline: false },
opnsenseApiKey: { isPublic: false, multiline: false },
opnsenseApiSecret: { isPublic: false, multiline: false },
patchmonApiKey: { isPublic: true, multiline: false },
patchmonApiSecret: { isPublic: false, multiline: false },
url: { isPublic: false, multiline: false },
privateKey: { isPublic: false, multiline: true },
githubAppId: { isPublic: true, multiline: false },
@@ -370,6 +372,15 @@ export const integrationDefs = {
documentationUrl: null,
defaultPort: 8000,
},
patchmon: {
name: "PatchMon",
secretKinds: [["patchmonApiKey", "patchmonApiSecret"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
defaultPort: 3413,
apiKeySettingsPath: "/settings/api-keys",
},
tracearr: {
name: "Tracearr",
secretKinds: [["apiKey"]],
@@ -458,6 +469,15 @@ export const integrationDefs = {
documentationUrl: null,
defaultUrl: "http://localhost:8001",
},
openWebUi: {
name: "Open WebUI",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
category: ["ai"],
documentationUrl: createDocumentationLink("/docs/integrations/open-webui"),
defaultPort: 8080,
apiKeySettingsPath: "/?settings=open#account",
},
// 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",
@@ -572,6 +592,7 @@ export const integrationCategories = [
"documents",
"mediaLibrary",
"uptimeMonitoring",
"ai",
"subtitleManager",
"reverseProxy",
] as const;
@@ -24,7 +24,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
indexerManager: getIntegrationKindsByCategory("indexerManager"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["emby", "jellyfin", "plex"],
@@ -34,6 +34,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
"immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"],
paperlessNgx: ["paperlessNgx"],
patchmon: ["patchmon"],
bazarr: ["bazarr"],
tracearr: ["tracearr"],
speedtestTracker: ["speedtestTracker"],
@@ -42,6 +43,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
openWebUi: ["openWebUi"],
traefik: ["traefik"],
};
+3
View File
@@ -37,6 +37,7 @@ export const widgetKinds = [
"immich-serverStats",
"immich-albumCarousel",
"paperlessNgx",
"patchmon",
"bazarr",
"tracearr",
"speedtestTracker",
@@ -52,6 +53,7 @@ export const widgetKinds = [
"beszelSystemStats",
"traefik",
"customApi",
"openWebUi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -59,6 +61,7 @@ export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; hei
uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 },
patchmon: { width: 2, height: 2 },
mediaMissing: { width: 4, height: 3 },
bazarr: { width: 2, height: 2 },
};
@@ -32,11 +32,13 @@ import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { OpenWebUiIntegration } from "../open-webui/open-webui-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
import { PatchMonIntegration } from "../patchmon/patchmon-integration";
import { PeaNutIntegration } from "../peanut/peanut-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-integration-factory";
@@ -120,6 +122,7 @@ export const integrationCreators = {
glances: GlancesIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
patchmon: PatchMonIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
@@ -129,6 +132,7 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
openWebUi: OpenWebUiIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
@@ -19,15 +19,66 @@ import type {
BeszelSystemDetails,
BeszelSystemdService,
BeszelSystemStatsRecord,
BeszelSystemStats,
BeszelContainerStats,
CreateAlertInput,
LiveStatsEvent,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "./pocketbase-realtime";
const logger = createLogger({ module: "beszel-integration" });
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const realtimeRetryDelaysMs = [200, 300, 500, 1_000, 1_500, 2_000] as const;
const supportsRealtimeMetrics = (version: string) => {
const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
return major > 0 || minor >= 13;
};
const waitForRetryAsync = async (delayMs: number, signal: AbortSignal) =>
await new Promise<void>((resolve) => {
if (signal.aborted) return resolve();
const timeout = setTimeout(done, delayMs);
signal.addEventListener("abort", done, { once: true });
function done() {
clearTimeout(timeout);
signal.removeEventListener("abort", done);
resolve();
}
});
interface BeszelRealtimeSnapshot {
stats?: BeszelSystemStats;
container?: BeszelContainerStats[];
}
export const normalizeRealtimeSnapshot = (
payload: BeszelRealtimeSnapshot,
systemId: string,
receivedAt = new Date(),
): LiveStatsEvent[] => {
const created = receivedAt.toISOString();
const id = `realtime-${receivedAt.getTime()}`;
const events: LiveStatsEvent[] = [];
if (payload.stats && typeof payload.stats === "object" && !Array.isArray(payload.stats)) {
events.push({
type: "system_stats",
record: { id, system: systemId, stats: payload.stats, type: "1m", created, updated: created },
});
}
if (Array.isArray(payload.container)) {
events.push({
type: "container_stats",
record: { id, system: systemId, stats: payload.container, type: "1m", created, updated: created },
});
}
return events;
};
interface BeszelSession {
token: string;
@@ -285,123 +336,163 @@ export class BeszelIntegration extends Integration {
});
}
/**
* Subscribe to real-time system and container stats via PocketBase SSE.
* Connects to the PocketBase Realtime API and parses incoming SSE events,
* filtering by systemId. This provides live-updating metrics (≤1s latency
* from agent) instead of polling the REST API every 5s.
*/
/** Subscribe to Beszel's custom one-second `rt_metrics` PocketBase topic. */
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise<void> {
const session = await this.authenticateAsync();
const realtimeUrl = this.url("/api/realtime");
logger.debug("Opening Beszel SSE connection for realtime metrics", {
integrationId: this.integration.id,
systemId,
url: realtimeUrl.pathname,
});
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: { Authorization: session.token },
signal,
});
if (!response.ok) {
logger.warn("Beszel SSE connection failed", {
integrationId: this.integration.id,
systemId,
status: response.status,
});
throw new ResponseError(response);
const systems = await this.getSystemsAsync();
const system = systems.find((candidate) => candidate.id === systemId);
if (!system) throw new Error("Selected Beszel system was not found");
if (system.status !== "up")
throw new Error(`Beszel system is ${system.status}; Live mode requires an online system`);
if (!supportsRealtimeMetrics(system.info.v)) {
throw new Error(`Beszel agent ${system.info.v} does not support Live mode; version 0.13.0 or newer is required`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body in SSE stream");
const decoder = new TextDecoder();
let buffer = "";
let subscribed = false;
const processLine = async (line: string) => {
if (!line.startsWith("data: ")) return;
const realtimeUrl = this.url("/api/realtime");
const topic = createRealtimeMetricsTopic(systemId);
let retryAttempt = 0;
let emittedSnapshotCount = 0;
while (!signal.aborted) {
try {
const parsed = JSON.parse(line.slice(6)) as Record<string, unknown>;
// PB_CONNECT delivers the clientId — use it to POST our subscriptions
if (!subscribed && typeof parsed.clientId === "string") {
subscribed = true;
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
let session = await this.authenticateAsync();
const openStream = async () =>
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: {
Accept: "text/event-stream",
Authorization: session.token,
"Content-Type": "application/json",
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
body: JSON.stringify({
clientId: parsed.clientId,
subscriptions: ["system_stats", "container_stats"],
}),
signal,
bodyTimeout: 0,
});
if (!subscribeResponse.ok) {
logger.warn("Beszel SSE subscription POST failed", {
logger.debug("Opening Beszel rt_metrics SSE connection", {
integrationId: this.integration.id,
systemId,
retryAttempt,
});
let response = await openStream();
if (response.status === 401) {
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await openStream();
}
if (!response.ok) throw new ResponseError(response);
const reader = response.body?.getReader();
if (!reader) throw new Error("Beszel realtime response has no body");
const decoder = new TextDecoder();
const parser = new PocketBaseSseParser();
let clientId: string | undefined;
const processFrameAsync = async (frame: ReturnType<PocketBaseSseParser["push"]>[number]) => {
if (frame.event === "PB_CONNECT") {
let fallbackClientId: string | undefined;
try {
const connectData = JSON.parse(frame.data) as { clientId?: string };
fallbackClientId = connectData.clientId;
} catch {
// PocketBase normally provides the client ID in the SSE id field.
}
clientId = frame.id || fallbackClientId;
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: { Authorization: session.token, "Content-Type": "application/json" },
body: JSON.stringify({ clientId, subscriptions: [topic] }),
signal,
});
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
logger.debug("Subscribed to Beszel rt_metrics topic", {
integrationId: this.integration.id,
systemId,
status: subscribeResponse.status,
clientId,
});
throw new ResponseError(subscribeResponse);
return;
}
logger.debug("Beszel SSE subscribed to collections", {
if (!frame.event.startsWith("rt_metrics")) return;
try {
const snapshot = JSON.parse(frame.data) as BeszelRealtimeSnapshot;
const events = normalizeRealtimeSnapshot(snapshot, systemId);
if (events.length === 0) return;
emittedSnapshotCount += 1;
retryAttempt = 0;
for (const event of events) onMessage(event);
if (emittedSnapshotCount === 1 || emittedSnapshotCount % 30 === 0) {
logger.debug("Forwarded Beszel rt_metrics snapshots", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
containerCount: snapshot.container?.length ?? 0,
});
}
} catch (error) {
logger.warn("Failed to parse Beszel rt_metrics payload", {
integrationId: this.integration.id,
systemId,
error: error instanceof Error ? error.message : String(error),
});
}
};
logger.debug("Beszel rt_metrics SSE connection established", {
integrationId: this.integration.id,
systemId,
contentType: response.headers.get("content-type"),
});
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
for (const frame of parser.push(decoder.decode(value, { stream: true }))) {
await processFrameAsync(frame);
}
}
for (const frame of parser.push(decoder.decode())) await processFrameAsync(frame);
for (const frame of parser.finish()) await processFrameAsync(frame);
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
} catch (error) {
if (signal.aborted) break;
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
retryAttempt += 1;
logger.debug("Retrying Beszel rt_metrics handshake after reauthentication", {
integrationId: this.integration.id,
systemId,
clientId: parsed.clientId,
});
return;
continue;
}
const record = parsed.record as Record<string, unknown> | undefined;
if (!record || record.system !== systemId) return;
if (Array.isArray(record.stats)) {
onMessage({
type: "container_stats",
record: record as unknown as BeszelContainerStatsRecord,
});
} else if (record.stats && typeof record.stats === "object") {
onMessage({
type: "system_stats",
record: record as unknown as BeszelSystemStatsRecord,
});
}
} catch (error) {
if (error instanceof ResponseError) throw error;
// skip malformed SSE data lines
if (error instanceof ResponseError && error.statusCode < 500 && error.statusCode !== 429) throw error;
const delayMs = realtimeRetryDelaysMs[Math.min(retryAttempt, realtimeRetryDelaysMs.length - 1)] ?? 2_000;
retryAttempt += 1;
logger.warn("Beszel rt_metrics connection lost; retrying", {
integrationId: this.integration.id,
systemId,
retryAttempt,
delayMs,
error: error instanceof Error ? error.message : String(error),
});
await waitForRetryAsync(delayMs, signal);
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
await processLine(line);
}
}
} finally {
reader.releaseLock();
logger.debug("Beszel SSE connection closed", { integrationId: this.integration.id, systemId });
}
logger.debug("Beszel rt_metrics subscription stopped", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
@@ -133,9 +133,9 @@ export interface BeszelSystemStats {
/** disk usage (%) */
dp: number;
/** disk read (MB/s) */
dr: number;
dr?: number;
/** disk write (MB/s) */
dw: number;
dw?: number;
/** disk read max (MB/s) */
drm?: number;
/** disk write max (MB/s) */
@@ -145,9 +145,9 @@ export interface BeszelSystemStats {
/** disk IOPS max [read, write] */
diom?: [number, number];
/** network sent — all interfaces (bytes/s) */
ns: number;
ns?: number;
/** network received — all interfaces (bytes/s) */
nr: number;
nr?: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number];
/** network sent max (bytes/s) */
@@ -169,26 +169,26 @@ export interface BeszelSystemStats {
}
export interface BeszelExtraFsStats {
/** total disk (bytes) */
/** total disk (GiB) */
d: number;
/** disk used (bytes) */
/** disk used (GiB) */
du: number;
/** read (bytes/s) */
r: number;
/** write (bytes/s) */
w: number;
/** read max (bytes/s) */
rm: number;
rm?: number;
/** write max (bytes/s) */
wm: number;
wm?: number;
/** read (IOPS) */
rb: number;
rb?: number;
/** write (IOPS) */
wb: number;
wb?: number;
/** read max (IOPS) */
rbm: number;
rbm?: number;
/** write max (IOPS) */
wbm: number;
wbm?: number;
}
export interface BeszelGPUData {
@@ -382,6 +382,8 @@ export interface BeszelSystemRow {
memory: number;
/** disk usage (%) */
disk: number;
/** extra filesystem usage (%) keyed by mount path */
extraFilesystems: Record<string, number>;
/** GPU usage (%) */
gpu: number;
/** load average [1m, 5m, 15m] */
@@ -0,0 +1,65 @@
export interface PocketBaseSseFrame {
event: string;
id?: string;
data: string;
}
export const createRealtimeMetricsTopic = (systemId: string) =>
`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: systemId } }))}`;
export class PocketBaseSseParser {
private buffer = "";
private event = "message";
private id: string | undefined;
private dataLines: string[] = [];
public push(chunk: string): PocketBaseSseFrame[] {
this.buffer += chunk;
const frames: PocketBaseSseFrame[] = [];
const lines = this.buffer.split(/\r?\n/);
this.buffer = lines.pop() ?? "";
for (const line of lines) {
const frame = this.processLine(line);
if (frame) frames.push(frame);
}
return frames;
}
public finish(): PocketBaseSseFrame[] {
const frames: PocketBaseSseFrame[] = [];
if (this.buffer !== "") {
const frame = this.processLine(this.buffer);
if (frame) frames.push(frame);
this.buffer = "";
}
const trailingFrame = this.dispatch();
if (trailingFrame) frames.push(trailingFrame);
return frames;
}
private processLine(line: string): PocketBaseSseFrame | null {
if (line === "") return this.dispatch();
if (line.startsWith(":")) return null;
const colonIndex = line.indexOf(":");
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
let value = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") this.event = value;
else if (field === "id") this.id = value;
else if (field === "data") this.dataLines.push(value);
return null;
}
private dispatch(): PocketBaseSseFrame | null {
if (this.dataLines.length === 0 && this.event === "message" && this.id === undefined) return null;
const frame = { event: this.event, id: this.id, data: this.dataLines.join("\n") };
this.event = "message";
this.id = undefined;
this.dataLines = [];
return frame;
}
}
@@ -40,7 +40,124 @@ vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
import { BeszelIntegration } from "../beszel-integration";
import { BeszelIntegration, normalizeRealtimeSnapshot } from "../beszel-integration";
import type { LiveStatsEvent } from "../beszel-types";
import { createRealtimeMetricsTopic } from "../pocketbase-realtime";
describe("normalizeRealtimeSnapshot", () => {
test("normalizes current Beszel host and container snapshots", () => {
const receivedAt = new Date("2026-07-11T13:51:30.217Z");
const events = normalizeRealtimeSnapshot(
{
stats: {
cpu: 18.07,
m: 31.09,
mu: 16.53,
mp: 53.18,
mb: 14.32,
s: 4,
su: 3.9,
d: 938.8,
du: 455.81,
dp: 51.15,
efs: { sda: { d: 1906.8, du: 1656.45, r: 25.63, w: 0 } },
},
container: [{ n: "homarr", c: 1.65, m: 650.56, b: [356066, 19702867] }],
},
"system-1",
receivedAt,
);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
type: "system_stats",
record: { system: "system-1", created: receivedAt.toISOString(), stats: { cpu: 18.07 } },
});
expect(events[1]).toMatchObject({
type: "container_stats",
record: { system: "system-1", stats: [{ n: "homarr", m: 650.56 }] },
});
});
test("ignores snapshots without metric data", () => {
expect(normalizeRealtimeSnapshot({}, "system-1")).toEqual([]);
});
});
describe("subscribeRealtimeMetrics", () => {
test("subscribes to Beszel rt_metrics and forwards one-second snapshots", async () => {
const originalFetch = globalThis.fetch;
const topic = createRealtimeMetricsTopic("system-1");
const subscriptionBodies: unknown[] = [];
const encoder = new TextEncoder();
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/collections/users/auth-with-password")) {
return Response.json({ token: "test-token", record: { id: "user-1" } });
}
if (url.includes("/api/collections/systems/records")) {
return Response.json({
page: 1,
perPage: 500,
totalItems: 1,
totalPages: 1,
items: [
{
id: "system-1",
name: "Server",
host: "localhost",
port: "45876",
status: "up",
info: { cpu: 1, mp: 2, dp: 3, u: 4, v: "0.18.7" },
created: "2026-07-11T00:00:00.000Z",
updated: "2026-07-11T00:00:00.000Z",
},
],
});
}
if (url.endsWith("/api/realtime") && init?.method === "POST") {
subscriptionBodies.push(JSON.parse(String(init.body)));
expect(new Headers(init.headers).get("Authorization")).toBe("test-token");
return new Response(null, { status: 204 });
}
if (url.endsWith("/api/realtime")) {
const snapshot = JSON.stringify({
stats: { cpu: 5, m: 10, mu: 4, mp: 40, mb: 1, s: 0, su: 0, d: 20, du: 8, dp: 40 },
container: [{ n: "homarr", c: 1, m: 256 }],
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: PB_CONNECT\nid: client-1\ndata: {}\n\n"));
controller.enqueue(encoder.encode(`event: ${topic}\ndata: ${snapshot}\n\n`));
controller.close();
},
});
return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
}
throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${url}`);
}) as typeof fetch;
try {
const integration = createBeszelIntegration();
const controller = new AbortController();
const events: LiveStatsEvent[] = [];
await integration.subscribeRealtimeMetrics(
"system-1",
(event) => {
events.push(event);
if (events.length === 2) controller.abort();
},
controller.signal,
);
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
function createBeszelIntegration() {
return new BeszelIntegration({
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "../pocketbase-realtime";
describe("PocketBaseSseParser", () => {
test("parses PB_CONNECT client IDs and CRLF-delimited frames split across chunks", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: PB_CONNECT\r\nid: client-")).toEqual([]);
expect(parser.push("123\r\ndata: {}\r\n\r\n")).toEqual([{ event: "PB_CONNECT", id: "client-123", data: "{}" }]);
});
test("joins multiline data and ignores keepalive comments", () => {
const parser = new PocketBaseSseParser();
expect(parser.push(': keepalive\n\nevent: rt_metrics\ndata: {"stats":\ndata: {}}\n\n')).toEqual([
{ event: "rt_metrics", id: undefined, data: '{"stats":\n{}}' },
]);
});
test("flushes a trailing frame when the stream ends without a blank line", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: rt_metrics\ndata: {}")).toEqual([]);
expect(parser.finish()).toEqual([{ event: "rt_metrics", id: undefined, data: "{}" }]);
});
});
describe("createRealtimeMetricsTopic", () => {
test("matches the PocketBase custom-topic options encoding used by Beszel", () => {
const topic = createRealtimeMetricsTopic("system-1");
expect(topic).toBe(`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: "system-1" } }))}`);
});
});
+2
View File
@@ -42,6 +42,7 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { OpenWebUiIntegration } from "./open-webui/open-webui-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
@@ -70,6 +71,7 @@ export type {
export type { Notification } from "./interfaces/notifications/notification-types";
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
export type { PatchMonStats, PatchMonOsDistributionEntry } from "./patchmon/patchmon-types";
export type {
AnchorNote,
AnchorNotePermission,
@@ -226,6 +226,7 @@ export class BeszelMockService {
u: randInt(86400, 2_000_000),
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
efs: i === 0 ? { "/mnt/sda": 86.9, "/mnt/sdb": 70.5, "/mnt/sdc": 42.7 } : undefined,
bb: rand(200, 3000) * KB,
v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0,
@@ -0,0 +1,372 @@
import { FormData } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
OpenWebUiChat,
OpenWebUiChatListItem,
OpenWebUiChatPayload,
OpenWebUiCompletionMessage,
OpenWebUiFileSummary,
OpenWebUiKnowledge,
OpenWebUiModel,
OpenWebUiNote,
OpenWebUiWebDocument,
} from "./open-webui-types";
import {
openWebUiChatListItemSchema,
openWebUiChatSchema,
openWebUiCollectionQueryResponseSchema,
openWebUiCompletionChunkSchema,
openWebUiFileListSchema,
openWebUiKnowledgeListSchema,
openWebUiModelsResponseSchema,
openWebUiNoteListSchema,
openWebUiNoteSchema,
openWebUiProcessWebResponseSchema,
openWebUiTranscriptionSchema,
openWebUiUploadedFileSchema,
} from "./open-webui-types";
const logger = createLogger({ module: "open-webui-integration" });
// Default per-request timeout so a stalled upstream can't hang model/file/note/
// chat calls indefinitely. Streaming completions opt out (they are long-lived).
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
// The (undici) request options accepted by the shared fetch helper. Derived so
// the request helpers stay in sync with the underlying fetch types.
type RequestOptions = NonNullable<Parameters<typeof fetchWithTrustedCertificatesAsync>[1]>;
export class OpenWebUiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/models"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
return { success: true };
}
/**
* List the models served by Open WebUI (aggregates all configured providers).
*/
public async getModelsAsync(): Promise<OpenWebUiModel[]> {
const parsed = await this.getJsonAsync("/api/models", openWebUiModelsResponseSchema);
return parsed.data;
}
/**
* List the knowledge bases (RAG collections) the user can ground chats on.
*/
public async getKnowledgeAsync(): Promise<OpenWebUiKnowledge[]> {
const parsed = await this.getJsonAsync("/api/v1/knowledge/", openWebUiKnowledgeListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's uploaded files (for the "Attach Files" picker).
*/
public async listFilesAsync(): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/**
* List the files contained in a knowledge base (for the expandable picker).
* The knowledge detail endpoint returns `files: null` on recent versions, so
* we read the files list and filter by the base's collection id instead.
*/
public async getKnowledgeFilesAsync(knowledgeId: string): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files
.filter((file) => file.meta?.collection_name === knowledgeId)
.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/** Fetch the raw file list, normalizing the paginated and bare-array shapes. */
private async fetchFilesAsync() {
const parsed = await this.getJsonAsync("/api/v1/files/", openWebUiFileListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's notes (for the "Attach Notes" picker).
*/
public async listNotesAsync(): Promise<OpenWebUiNote[]> {
const parsed = await this.getJsonAsync("/api/v1/notes/", openWebUiNoteListSchema);
const notes = Array.isArray(parsed) ? parsed : parsed.items;
return notes.map((note) => ({
id: note.id,
title: note.title ?? "Untitled note",
content: note.data?.content?.md ?? "",
}));
}
/**
* Get a single note including its full markdown content. The list endpoint
* truncates the body, so we fetch the note directly when attaching it.
*/
public async getNoteAsync(noteId: string): Promise<OpenWebUiNote> {
const parsed = await this.getJsonAsync(`/api/v1/notes/${encodeURIComponent(noteId)}`, openWebUiNoteSchema);
return { id: parsed.id, title: parsed.title ?? "Untitled note", content: parsed.data?.content?.md ?? "" };
}
/**
* Transcribe recorded audio to text (speech-to-text) for voice messages.
*/
public async transcribeAudioAsync(filename: string, base64Content: string, contentType: string): Promise<string> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/audio/transcriptions", form, openWebUiTranscriptionSchema);
return parsed.text;
}
/**
* Upload a file to Open WebUI so it can be retrieved from later. Returns the
* new file id (its vector collection is `file-{id}`).
*/
public async uploadFileAsync(
filename: string,
base64Content: string,
contentType: string,
): Promise<OpenWebUiFileSummary> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/files/", form, openWebUiUploadedFileSchema);
return { id: parsed.id, name: parsed.meta?.name ?? parsed.filename ?? filename };
}
/**
* Ingest a web page into a temporary vector collection and return a handle to
* retrieve from it. Open WebUI does not crawl URLs at completion time, so we
* embed the page here and inject retrieved chunks ourselves at send time.
*/
public async processWebAsync(url: string): Promise<OpenWebUiWebDocument> {
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/process/web",
{ url, collection_name: "" },
openWebUiProcessWebResponseSchema,
);
return { collectionName: parsed.collection_name, title: parsed.file?.meta?.name ?? url };
}
/**
* Retrieve the most relevant chunks for a query across the given collections
* (web pages and/or knowledge bases). Returns the chunk texts, best-effort.
*/
public async queryCollectionsAsync(collectionNames: string[], query: string, k = 4): Promise<string[]> {
if (collectionNames.length === 0 || query.trim() === "") {
return [];
}
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/query/collection",
{ collection_names: collectionNames, query, k },
openWebUiCollectionQueryResponseSchema,
);
return (parsed.documents ?? []).flat();
}
/**
* List the user's chats (native Open WebUI history).
*/
public async listChatsAsync(): Promise<OpenWebUiChatListItem[]> {
return await this.getJsonAsync("/api/v1/chats/", openWebUiChatListItemSchema.array());
}
/**
* Get a single chat including its messages.
*/
public async getChatAsync(chatId: string): Promise<OpenWebUiChat> {
return await this.getJsonAsync(`/api/v1/chats/${encodeURIComponent(chatId)}`, openWebUiChatSchema);
}
/**
* Create a new chat in the native history.
*/
public async createChatAsync(payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync("/api/v1/chats/new", { chat: payload }, openWebUiChatSchema);
}
/**
* Update an existing chat in the native history.
*/
public async updateChatAsync(chatId: string, payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync(
`/api/v1/chats/${encodeURIComponent(chatId)}`,
{ chat: payload },
openWebUiChatSchema,
);
}
/**
* Stream an assistant completion token-by-token.
* Reads the OpenAI-compatible SSE response and invokes `onDelta` for each
* content delta. Resolves when the stream completes ([DONE]) or is aborted.
*/
public async streamChatCompletionAsync(
params: { model: string; messages: OpenWebUiCompletionMessage[] },
onDelta: (delta: string) => void,
signal: AbortSignal,
): Promise<void> {
const response = await this.requestAsync("/api/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: params.model, messages: params.messages, stream: true }),
// Long-lived stream: opt out of the default request timeout and rely on the
// caller's abort signal instead (the timeout path would also replace this
// signal, breaking cancellation).
timeout: undefined,
signal,
});
if (!response.body) {
throw new ResponseError(response);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const processLine = (line: string): boolean => {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return false;
const payload = trimmed.slice(5).trim();
if (payload === "") return false;
if (payload === "[DONE]") return true;
try {
const parsed = openWebUiCompletionChunkSchema.parse(JSON.parse(payload));
const choice = parsed.choices?.[0];
if (choice?.delta?.content) {
onDelta(choice.delta.content);
}
// Some providers end the stream with a finish_reason chunk and no
// explicit [DONE] sentinel; treat that as a clean completion too.
if (choice?.finish_reason) return true;
} catch (error) {
// The chunk can contain model output or grounded user data, so log only
// safe metadata rather than the raw payload.
logger.debug("Failed to parse Open WebUI completion chunk", {
error: error instanceof Error ? error.message : "unknown",
payloadLength: payload.length,
});
}
return false;
};
let completed = false;
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) {
// The server may close after the final data: event without a trailing
// newline; parse whatever is left so a valid completion isn't treated
// as a truncated stream.
if (buffer.trim() !== "" && processLine(buffer)) {
completed = true;
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (processLine(line)) {
completed = true;
break;
}
}
if (completed) break;
}
} finally {
reader.cancel().catch(() => {
// ignore cancellation errors during teardown
});
}
// The reader ended without a [DONE]/finish_reason marker: the upstream
// connection was cut mid-stream. Surface it instead of presenting the
// partial reply as a complete answer.
if (!signal.aborted && !completed) {
throw new Error("Open WebUI stream ended before completion");
}
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
};
}
/**
* Issue an authenticated request against the Open WebUI API and throw a
* {@link ResponseError} on a non-2xx status. Centralizes the auth header,
* trusted-certificate fetch and error handling shared by every endpoint.
*/
private async requestAsync(
path: `/${string}`,
init?: Omit<RequestOptions, "headers"> & { headers?: Record<string, string> },
) {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
// Apply a default timeout unless the caller opts out (e.g. streaming
// completions pass `timeout: undefined` to stay open indefinitely). A
// spread after this key lets an explicit caller value win.
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
...init,
headers: { ...this.getAuthHeaders(), ...init?.headers },
});
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
/** GET an endpoint and validate the JSON body against `schema`. */
private async getJsonAsync<TOutput>(
path: `/${string}`,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path);
return schema.parse(await response.json());
}
/** POST a JSON body and validate the JSON response against `schema`. */
private async postJsonAsync<TOutput>(
path: `/${string}`,
body: unknown,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return schema.parse(await response.json());
}
/** POST a multipart form (file upload) and validate the JSON response. */
private async postFormAsync<TOutput>(
path: `/${string}`,
form: FormData,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, { method: "POST", body: form });
return schema.parse(await response.json());
}
}
@@ -0,0 +1,238 @@
// Open WebUI API types and schemas.
// Open WebUI exposes an OpenAI-compatible API plus its own native chat-history API.
// Schemas are intentionally defensive (`.nullish()`) because the native chat
// endpoints are undocumented and vary between Open WebUI versions.
import { z } from "zod/v4";
// GET /api/models — OpenAI-compatible. Open WebUI returns `{ data: [...] }`.
export const openWebUiModelSchema = z.object({
id: z.string(),
name: z.string().nullish(),
object: z.string().nullish(),
owned_by: z.string().nullish(),
// Open WebUI exposes per-model capabilities here when configured; `vision`
// tells us whether the model can accept image attachments.
info: z
.object({
meta: z
.object({
capabilities: z.object({ vision: z.boolean().nullish() }).nullish(),
})
.nullish(),
})
.nullish(),
});
export const openWebUiModelsResponseSchema = z.object({
data: z.array(openWebUiModelSchema),
});
export type OpenWebUiModel = z.infer<typeof openWebUiModelSchema>;
// POST /api/chat/completions (stream: true) — OpenAI-compatible SSE delta chunk.
export const openWebUiCompletionChunkSchema = z.object({
id: z.string().nullish(),
choices: z
.array(
z.object({
delta: z
.object({
role: z.string().nullish(),
content: z.string().nullish(),
})
.nullish(),
finish_reason: z.string().nullish(),
}),
)
.nullish(),
});
// Multimodal content part (OpenAI vision format). Lets a message carry text
// and/or images.
export const openWebUiContentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
// Message content is either plain text or multimodal parts. Both the completion
// and the native chat/history contracts use this so an attachment-backed message
// round-trips through create/update/get without parse failures or lossy drops.
export const openWebUiMessageContentSchema = z.union([z.string(), z.array(openWebUiContentPartSchema)]);
// A single chat message exchanged with the model / persisted in a chat.
export const openWebUiChatMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiChatMessage = z.infer<typeof openWebUiChatMessageSchema>;
// A completion message can carry plain text or multimodal parts (text + images).
export const openWebUiCompletionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiCompletionMessage = z.infer<typeof openWebUiCompletionMessageSchema>;
// GET /api/v1/knowledge/ — knowledge bases (RAG collections owned by the user).
// Newer Open WebUI versions paginate the list as `{ items, total }`; older ones
// return a bare array. Accept both and normalise in the integration.
export const openWebUiKnowledgeSchema = z.object({
id: z.string(),
name: z.string().nullish(),
description: z.string().nullish(),
});
export type OpenWebUiKnowledge = z.infer<typeof openWebUiKnowledgeSchema>;
export const openWebUiKnowledgeListSchema = z.union([
z.array(openWebUiKnowledgeSchema),
z.object({ items: z.array(openWebUiKnowledgeSchema) }),
]);
// A file stored in Open WebUI (uploaded directly or part of a knowledge base).
// `meta.collection_name` is the knowledge base id when the file belongs to one.
export const openWebUiFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z
.object({
name: z.string().nullish(),
content_type: z.string().nullish(),
collection_name: z.string().nullish(),
})
.nullish(),
});
export type OpenWebUiFile = z.infer<typeof openWebUiFileSchema>;
export const openWebUiFileListSchema = z.union([
z.array(openWebUiFileSchema),
z.object({ items: z.array(openWebUiFileSchema) }),
]);
// POST /api/v1/files/ — upload response (we only need the new file id + name).
export const openWebUiUploadedFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z.object({ name: z.string().nullish() }).nullish(),
});
// GET /api/v1/knowledge/{id} — knowledge base with its files.
export const openWebUiKnowledgeDetailSchema = z.object({
id: z.string(),
name: z.string().nullish(),
files: z.array(openWebUiFileSchema).nullish(),
});
export type OpenWebUiKnowledgeDetail = z.infer<typeof openWebUiKnowledgeDetailSchema>;
// GET /api/v1/notes/ — user notes (markdown content under data.content.md).
export const openWebUiNoteSchema = z.object({
id: z.string(),
title: z.string().nullish(),
data: z.object({ content: z.object({ md: z.string().nullish() }).nullish() }).nullish(),
});
export const openWebUiNoteListSchema = z.union([
z.array(openWebUiNoteSchema),
z.object({ items: z.array(openWebUiNoteSchema) }),
]);
// A note flattened for the widget: id, title and its markdown body.
export interface OpenWebUiNote {
id: string;
title: string;
content: string;
}
// POST /api/v1/audio/transcriptions — speech-to-text result.
export const openWebUiTranscriptionSchema = z.object({ text: z.string() });
// A file flattened for the widget: id and a display name.
export interface OpenWebUiFileSummary {
id: string;
name: string;
}
// POST /api/v1/retrieval/process/web — ingests a URL into a temporary vector
// collection. We retrieve from that collection at send time and inject the
// results as context (Open WebUI does not fetch URLs at completion time).
export const openWebUiProcessWebResponseSchema = z.object({
collection_name: z.string(),
filename: z.string().nullish(),
file: z.object({ meta: z.object({ name: z.string().nullish() }).nullish() }).nullish(),
});
// POST /api/v1/retrieval/query/collection — top-k chunks per collection.
export const openWebUiCollectionQueryResponseSchema = z.object({
documents: z.array(z.array(z.string())).nullish(),
});
// A web page ingested for grounding: the collection to retrieve from + a label.
export interface OpenWebUiWebDocument {
collectionName: string;
title: string;
}
// GET /api/v1/chats/ — list of the user's chats (native Open WebUI shape).
export const openWebUiChatListItemSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
});
export type OpenWebUiChatListItem = z.infer<typeof openWebUiChatListItemSchema>;
// GET /api/v1/chats/{id} — full chat.
// Two message representations exist: a flat `chat.messages` list (used by chats
// we create) and a `chat.history` tree keyed by message id with a `currentId`
// leaf (used by chats created in the Open WebUI UI). The history tree is
// authoritative when present, since `chat.messages` can be stale/partial.
export const openWebUiHistoryMessageSchema = z.object({
id: z.string().nullish(),
parentId: z.string().nullish(),
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
});
export const openWebUiChatSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
chat: z
.object({
title: z.string().nullish(),
models: z.array(z.string()).nullish(),
messages: z
.array(
z.object({
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
}),
)
.nullish(),
history: z
.object({
currentId: z.string().nullish(),
messages: z.record(z.string(), openWebUiHistoryMessageSchema).nullish(),
})
.nullish(),
})
.nullish(),
});
export type OpenWebUiChat = z.infer<typeof openWebUiChatSchema>;
// Body used when creating/updating a chat through the native API. `meta.tags`
// lets Homarr tag a conversation (e.g. with the owning Homarr user id) so it can
// be found back in Open WebUI even when an integration/account is shared.
export interface OpenWebUiChatPayload {
title: string;
models: string[];
messages: OpenWebUiChatMessage[];
meta?: { tags?: string[] };
}
@@ -0,0 +1,230 @@
import { Request, Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { OpenWebUiIntegration } from "../open-webui-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_API_KEY = "test-api-key-12345";
const TEST_URL = "https://openwebui.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const asFetchResult = (response: Response) =>
response as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const setupMockFetch = (responses: Record<string, Record<string, unknown> | unknown[]>) => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
const path = new URL(urlString).pathname;
if (path in responses) {
return Promise.resolve(
asFetchResult(
new Response(JSON.stringify(responses[path]), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
}
return Promise.resolve(asFetchResult(new Response(JSON.stringify({ error: "Not Found" }), { status: 404 })));
});
};
const streamFrom = (chunks: string[]) =>
new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
const createIntegration = () =>
new OpenWebUiIntegration({
id: "test-open-webui",
name: "Test Open WebUI",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
});
describe("OpenWebUiIntegration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getModelsAsync", () => {
test("parses the OpenAI-compatible model list", async () => {
setupMockFetch({
"/api/models": { data: [{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }] },
});
const result = await createIntegration().getModelsAsync();
expect(result).toEqual([{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }]);
expect(mockFetch).toHaveBeenCalled();
});
test("sends the bearer token", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const headers = mockFetch.mock.calls.at(0)?.[1]?.headers as Record<string, string> | undefined;
expect(headers?.Authorization).toBe(`Bearer ${TEST_API_KEY}`);
});
test("throws when the API returns an error", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Unauthorized", { status: 401 })));
await expect(createIntegration().getModelsAsync()).rejects.toThrow();
});
});
describe("streamChatCompletionAsync", () => {
test("emits each content delta and resolves on [DONE]", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hel" } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { content: "lo" } }] })}\n`,
"data: [DONE]\n",
]),
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas).toEqual(["Hel", "lo"]);
});
test("handles a delta split across stream chunks", async () => {
const payload = JSON.stringify({ choices: [{ delta: { content: "world" } }] });
const half = Math.floor(payload.length / 2);
mockFetch.mockResolvedValue(
asFetchResult(
new Response(streamFrom([`data: ${payload.slice(0, half)}`, `${payload.slice(half)}\n`, "data: [DONE]\n"]), {
status: 200,
}),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas.join("")).toBe("world");
});
test("throws when the completion endpoint errors", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Bad Gateway", { status: 502 })));
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
),
).rejects.toThrow();
});
test("flushes the final SSE frame when it has no trailing newline", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "done" } }] })}\n`,
// Final event: no trailing newline and ends via finish_reason (no [DONE]).
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}`,
]),
{ status: 200 },
),
),
);
const deltas: string[] = [];
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
),
).resolves.toBeUndefined();
expect(deltas.join("")).toBe("done");
});
});
describe("requestAsync timeouts", () => {
test("applies a default timeout to non-streaming requests", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeGreaterThan(0);
});
test("does not time out the long-lived streaming completion", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response(streamFrom(["data: [DONE]\n"]), { status: 200 })));
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
);
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeUndefined();
});
});
describe("getChatAsync", () => {
test("parses a chat whose message content is multimodal (text + image parts)", async () => {
setupMockFetch({
"/api/v1/chats/abc": {
id: "abc",
chat: {
title: "Vision",
models: ["llava"],
messages: [
{
role: "user",
content: [
{ type: "text", text: "What is this?" },
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
],
},
{ role: "assistant", content: "A cat." },
],
},
},
});
const chat = await createIntegration().getChatAsync("abc");
expect(chat.id).toBe("abc");
expect(chat.chat?.messages?.[0]?.content).toBeDefined();
});
});
});
@@ -0,0 +1,58 @@
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 { PatchMonStats } from "./patchmon-types";
import { mapPatchMonStats, parsePatchMonStatsResponseAsync } from "./patchmon-types";
const STATS_REQUEST_TIMEOUT_MS = 10_000;
export class PatchMonIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v1/gethomepage/stats"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
try {
await parsePatchMonStatsResponseAsync(response);
} catch (error) {
if (error instanceof ParseError) {
return TestConnectionError.ParseResult(error);
}
throw error;
}
return { success: true };
}
public async getStatsAsync(): Promise<PatchMonStats> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/gethomepage/stats"), {
headers: this.getAuthHeaders(),
timeout: STATS_REQUEST_TIMEOUT_MS,
});
if (!response.ok) {
throw new ResponseError(response);
}
const data = await parsePatchMonStatsResponseAsync(response);
return mapPatchMonStats(data);
}
private getAuthHeaders(): Record<string, string> {
const credentials = Buffer.from(
`${this.getSecretValue("patchmonApiKey")}:${this.getSecretValue("patchmonApiSecret")}`,
).toString("base64");
return { Authorization: `Basic ${credentials}` };
}
}
@@ -0,0 +1,88 @@
import { ParseError } from "@homarr/common/server";
import { z } from "zod/v4";
const PATCHMON_STATS_PARSE_ERROR_MESSAGE = "Invalid PatchMon stats response";
export const patchmonOsDistributionEntrySchema = z.object({
name: z.string(),
count: z.number(),
os_type: z.string().optional(),
os_version: z.string().optional(),
});
export const patchmonStatsResponseSchema = z.object({
total_hosts: z.number(),
hosts_needing_updates: z.number(),
security_updates: z.number(),
up_to_date_hosts: z.number(),
hosts_with_security_updates: z.number(),
recent_updates_24h: z.number(),
total_outdated_packages: z.number(),
total_repos: z.number(),
last_updated: z.string(),
os_distribution: z.array(patchmonOsDistributionEntrySchema).default([]),
});
export interface PatchMonOsDistributionEntry {
name: string;
count: number;
osType?: string;
osVersion?: string;
}
export interface PatchMonStats {
totalHosts: number;
hostsNeedingUpdates: number;
securityUpdates: number;
upToDateHosts: number;
hostsWithSecurityUpdates: number;
recentUpdates24h: number;
totalOutdatedPackages: number;
totalRepos: number;
lastUpdated: string;
osDistribution: PatchMonOsDistributionEntry[];
}
const mapOsDistributionEntry = (
entry: z.infer<typeof patchmonOsDistributionEntrySchema>,
): PatchMonOsDistributionEntry => ({
name: entry.name,
count: entry.count,
osType: entry.os_type,
osVersion: entry.os_version,
});
export const parsePatchMonStatsResponseAsync = async (
response: { json: () => Promise<unknown> },
): Promise<z.infer<typeof patchmonStatsResponseSchema>> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError(PATCHMON_STATS_PARSE_ERROR_MESSAGE, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const parseResult = await patchmonStatsResponseSchema.safeParseAsync(json);
if (!parseResult.success) {
throw new ParseError(PATCHMON_STATS_PARSE_ERROR_MESSAGE, { cause: parseResult.error });
}
return parseResult.data;
};
export const mapPatchMonStats = (data: z.infer<typeof patchmonStatsResponseSchema>): PatchMonStats => ({
totalHosts: data.total_hosts,
hostsNeedingUpdates: data.hosts_needing_updates,
securityUpdates: data.security_updates,
upToDateHosts: data.up_to_date_hosts,
hostsWithSecurityUpdates: data.hosts_with_security_updates,
recentUpdates24h: data.recent_updates_24h,
totalOutdatedPackages: data.total_outdated_packages,
totalRepos: data.total_repos,
lastUpdated: data.last_updated,
osDistribution: [...data.os_distribution]
.map(mapOsDistributionEntry)
.sort((a, b) => b.count - a.count),
});
@@ -0,0 +1,167 @@
// @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 { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
import type { IntegrationSecret } from "../../base/types";
import { PatchMonIntegration } from "../patchmon-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://patchmon.example.com";
const TEST_API_KEY = "test-api-key";
const TEST_API_SECRET = "test-api-secret";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const sampleStatsResponse = {
total_hosts: 42,
hosts_needing_updates: 15,
security_updates: 23,
up_to_date_hosts: 27,
hosts_with_security_updates: 8,
recent_updates_24h: 34,
total_outdated_packages: 156,
total_repos: 12,
last_updated: "2025-10-11T12:34:56.789Z",
os_distribution: [
{ name: "Debian", count: 12, os_type: "linux", os_version: "12" },
{ name: "Ubuntu", count: 20, os_type: "linux", os_version: "22.04" },
{ name: "Rocky Linux", count: 10, os_type: "linux", os_version: "9" },
],
};
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new PatchMonIntegration({
id: "test-patchmon",
name: "Test PatchMon",
url: TEST_URL,
externalUrl: null,
decryptedSecrets:
decryptedSecrets.length > 0
? decryptedSecrets
: [
{ kind: "patchmonApiKey", value: TEST_API_KEY },
{ kind: "patchmonApiSecret", value: TEST_API_SECRET },
],
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("PatchMonIntegration getStatsAsync", () => {
test("maps API response fields to camelCase stats", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleStatsResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const stats = await createIntegration().getStatsAsync();
expect(stats).toStrictEqual({
totalHosts: 42,
hostsNeedingUpdates: 15,
securityUpdates: 23,
upToDateHosts: 27,
hostsWithSecurityUpdates: 8,
recentUpdates24h: 34,
totalOutdatedPackages: 156,
totalRepos: 12,
lastUpdated: "2025-10-11T12:34:56.789Z",
osDistribution: [
{ name: "Ubuntu", count: 20, osType: "linux", osVersion: "22.04" },
{ name: "Debian", count: 12, osType: "linux", osVersion: "12" },
{ name: "Rocky Linux", count: 10, osType: "linux", osVersion: "9" },
],
});
});
test("defaults os distribution to empty array when absent", async () => {
const { os_distribution: _osDistribution, ...responseWithoutOs } = sampleStatsResponse;
mockFetch.mockResolvedValue(
new Response(JSON.stringify(responseWithoutOs), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const stats = await createIntegration().getStatsAsync();
expect(stats.osDistribution).toStrictEqual([]);
});
test("throws ParseError when API response is not valid JSON", async () => {
mockFetch.mockResolvedValue(
new Response("not-json", {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<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 PatchMon stats response");
});
});
test("throws ParseError when API response does not match schema", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ invalid: true }), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<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 PatchMon stats response");
});
});
test("throws when API returns an error", async () => {
mockFetch.mockResolvedValue(
new Response("Unauthorized", {
status: 401,
headers: { "content-type": "text/plain" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
});
});
describe("PatchMonIntegration authentication", () => {
test("sends a Basic auth header with api key and secret", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleStatsResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await createIntegration().getStatsAsync();
const expected = `Basic ${Buffer.from(`${TEST_API_KEY}:${TEST_API_SECRET}`).toString("base64")}`;
const [url, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(String(url)).toContain("/api/v1/gethomepage/stats");
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
expect(requestInit?.timeout).toBe(10_000);
});
});
@@ -6,6 +6,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { SessionStore } from "../base/session-store";
import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types";
import {
AUTH_API_NAME,
INFO_API_NAME,
@@ -23,8 +24,6 @@ import {
synologyUpgradeCheckDataSchema,
synologyUpgradeStatusDataSchema,
synologyUtilizationDataSchema,
type SynologyDiskRecord,
type SynologyVolumeRecord,
} from "./synology-types";
const logger = createLogger({ module: "synologyClient" });
@@ -158,13 +157,7 @@ export class SynologyClient {
if (!name) {
return null;
}
return this.mapVolumeRecord(
name,
volume.size?.used,
volume.size?.total,
volume.status,
volume.display_name,
);
return this.mapVolumeRecord(name, volume.size?.used, volume.size?.total, volume.status, volume.display_name);
})
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
}
@@ -2,6 +2,7 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { z } from "zod";
vi.hoisted(() => {
process.env.CI = "true";
@@ -37,6 +38,7 @@ import { IntegrationResponseError } from "../../base/errors/http/integration-res
import type { IntegrationSecret } from "../../base/types";
import { SynologyIntegration } from "../synology-integration";
import type { synologyStorageV2DataSchema, synologyUtilizationDataSchema } from "../synology-types";
const TEST_URL = "https://synology.example.com:5001";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
@@ -202,8 +204,8 @@ const mockSuccessfulFlow = (options?: {
success: boolean;
data: Record<string, unknown>;
};
utilization?: typeof utilizationResponse;
storageV2?: typeof storageV2Response;
utilization?: { success: boolean; data: z.input<typeof synologyUtilizationDataSchema> };
storageV2?: { success: boolean; data: z.input<typeof synologyStorageV2DataSchema> };
storageLoadInfo?: typeof storageLoadInfoResponse;
}) => {
mockFetch.mockImplementation((url) => {
@@ -241,9 +243,9 @@ const mockSuccessfulFlow = (options?: {
new Response(JSON.stringify({ success: false, error: { code: 103 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
return Promise.resolve(
new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage") {
+1
View File
@@ -26,4 +26,5 @@ export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
export * from "./open-webui/open-webui-types";
export * from "./traefik/traefik-types";
+1
View File
@@ -34,6 +34,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
cpu: info.cpu,
memory: info.mp,
disk: info.dp,
extraFilesystems: info.efs ?? {},
gpu: info.g ?? 0,
loadAvg: info.la ?? null,
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
export const systemInfoRequestHandler = createIntegrationRequestHandler<
SystemHealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
Record<string, never>
>({
async requestAsync(integration, _input) {
@@ -1,10 +1,8 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
interface MediaOrganizerData {
missing: MissingMediaItem[];
@@ -13,7 +11,7 @@ interface MediaOrganizerData {
queuedCount: number;
}
export const mediaOrganizerRequestHandler = createCachedIntegrationRequestHandler<
export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
MediaOrganizerData,
IntegrationKindByCategory<"mediaOrganizer">,
{ pageSize: number }
@@ -31,6 +29,5 @@ export const mediaOrganizerRequestHandler = createCachedIntegrationRequestHandle
queuedCount: queueResult.totalCount,
};
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "mediaOrganizer",
cacheTtlMs: 60_000,
});
+109
View File
@@ -0,0 +1,109 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
OpenWebUiChat,
OpenWebUiChatListItem,
OpenWebUiFileSummary,
OpenWebUiKnowledge,
OpenWebUiModel,
OpenWebUiNote,
} from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const openWebUiModelsRequestHandler = createIntegrationRequestHandler<
OpenWebUiModel[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getModelsAsync();
},
cacheTtlMs: dayjs.duration(5, "minutes").asMilliseconds(),
});
export const openWebUiKnowledgeRequestHandler = createIntegrationRequestHandler<
OpenWebUiKnowledge[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getKnowledgeAsync();
},
cacheTtlMs: dayjs.duration(5, "minutes").asMilliseconds(),
});
export const openWebUiKnowledgeFilesRequestHandler = createIntegrationRequestHandler<
OpenWebUiFileSummary[],
"openWebUi",
{ knowledgeId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getKnowledgeFilesAsync(input.knowledgeId);
},
cacheTtlMs: dayjs.duration(1, "minute").asMilliseconds(),
});
export const openWebUiFilesRequestHandler = createIntegrationRequestHandler<
OpenWebUiFileSummary[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listFilesAsync();
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiNotesRequestHandler = createIntegrationRequestHandler<
OpenWebUiNote[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listNotesAsync();
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiNoteRequestHandler = createIntegrationRequestHandler<
OpenWebUiNote,
"openWebUi",
{ noteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getNoteAsync(input.noteId);
},
cacheTtlMs: dayjs.duration(30, "seconds").asMilliseconds(),
});
export const openWebUiChatsRequestHandler = createIntegrationRequestHandler<
OpenWebUiChatListItem[],
"openWebUi",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.listChatsAsync();
},
cacheTtlMs: dayjs.duration(15, "seconds").asMilliseconds(),
});
export const openWebUiChatRequestHandler = createIntegrationRequestHandler<
OpenWebUiChat,
"openWebUi",
{ chatId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getChatAsync(input.chatId);
},
cacheTtlMs: dayjs.duration(15, "seconds").asMilliseconds(),
});
+15
View File
@@ -0,0 +1,15 @@
import { createIntegrationAsync } from "@homarr/integrations";
import type { PatchMonStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const patchmonStatsRequestHandler = createIntegrationRequestHandler<
PatchMonStats,
"patchmon",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
},
});
@@ -134,7 +134,7 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
baseUrl,
auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { enabled: false },
throttle: { enabled: true },
userAgent,
});
+13 -3
View File
@@ -1,9 +1,9 @@
"use client";
import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
import { mediaRequestSearchEvent, openWebUiChatEvent, spotlightActions } from "./spotlight-store";
export { Spotlight } from "./components/spotlight";
export { openSpotlight, openMediaRequestSearch };
export { openSpotlight, openMediaRequestSearch, openOpenWebUiChat };
export {
SpotlightProvider,
useRegisterSpotlightContextResults,
@@ -25,4 +25,14 @@ const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) =>
spotlightActions.open();
};
export { mediaRequestSearchEvent };
export { mediaRequestSearchEvent, openWebUiChatEvent };
// Opens the Open WebUI chat side panel, optionally seeding it with a query to
// start a conversation from. Closes the spotlight so the panel takes over.
const openOpenWebUiChat = (query?: string) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<{ query?: string }>(openWebUiChatEvent, { detail: { query } }));
}
spotlightActions.close();
};
@@ -0,0 +1,61 @@
import { Group, Text } from "@mantine/core";
import { IconRobot } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { openWebUiChatEvent } from "../../spotlight-store";
interface AskAiOption {
key: string;
query: string;
}
// Opening the panel is decoupled from the app via a window event so the
// spotlight package doesn't depend on the Next.js app.
const dispatchOpenChat = (query: string) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<{ query?: string }>(openWebUiChatEvent, { detail: { query } }));
}
};
// "Ask AI" — starts an Open WebUI conversation from the spotlight query.
export const askAiGroup = createGroup<AskAiOption>({
keyPath: "key",
title: (t) => t("search.mode.askAi.title"),
Component: (option) => {
const t = useScopedI18n("search.mode.askAi.group.ask");
return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<IconRobot stroke={1.5} />
<Text>{option.query ? t("startWithQuery", { query: option.query }) : t("hint")}</Text>
</Group>
);
},
useInteraction: interaction.javaScript((_option, query) => ({
onSelect() {
dispatchOpenChat(query);
},
closeSpotlightOnTrigger: true,
})),
useQueryOptions(query) {
const { data: session } = useSession();
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const hasOpenWebUiIntegration = integrations.some(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session?.user?.id,
);
return {
data: hasOpenWebUiIntegration ? [{ key: "ask-ai", query }] : [],
isLoading: false,
isError: false,
};
},
});
@@ -0,0 +1,8 @@
import type { SearchMode } from "../../lib/mode";
import { askAiGroup } from "./ask-ai-group";
export const askAiMode = {
modeKey: "askAi",
character: "?",
groups: [askAiGroup],
} satisfies SearchMode;
@@ -8,6 +8,7 @@ import {
IconSettings,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
@@ -17,6 +18,7 @@ import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board";
import { askAiMode } from "../ask-ai";
import { commandMode } from "../command";
import { externalMode } from "../external";
import { mediaMode } from "../media";
@@ -40,6 +42,18 @@ export const useHomeEmptyGroups = () => {
visibleSearchModes.unshift(userGroupMode);
}
// Only surface the "Ask AI" mode when the user owns an Open WebUI integration.
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
if (
integrations.some((integration) => integration.kind === "openWebUi" && integration.creatorId === session?.user?.id)
) {
visibleSearchModes.push(askAiMode);
}
return [
createGroup({
keyPath: "key",
+2
View File
@@ -1,4 +1,5 @@
import { appIntegrationBoardMode } from "./app-integration-board";
import { askAiMode } from "./ask-ai";
import { commandMode } from "./command";
import { externalMode } from "./external";
import { homeMode } from "./home";
@@ -13,5 +14,6 @@ export const searchModes = [
commandMode,
pageMode,
mediaMode,
askAiMode,
homeMode,
] as const;
@@ -14,6 +14,10 @@ export interface MediaRequestSearchScope {
export const mediaRequestSearchScopeAtom = atom<MediaRequestSearchScope>({});
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
// Dispatched to open the Open WebUI chat side panel (optionally with a query to
// start a conversation from). The app-level provider listens for this.
export const openWebUiChatEvent = "homarr:spotlight:open-web-ui-chat";
export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index }));
};
+238 -5
View File
@@ -1285,6 +1285,14 @@
"label": "API Key (Secret)",
"newLabel": "New API Key (Secret)"
},
"patchmonApiKey": {
"label": "API Key",
"newLabel": "New API Key"
},
"patchmonApiSecret": {
"label": "API Secret",
"newLabel": "New API Secret"
},
"githubAppId": {
"label": "App Id",
"newLabel": "New App Id"
@@ -1336,6 +1344,10 @@
"name": "Paperless-ngx",
"description": "Document management system for archiving and organizing scanned documents"
},
"patchmon": {
"name": "PatchMon",
"description": "Patch management and monitoring for Linux hosts"
},
"bazarr": {
"name": "Bazarr",
"description": "Companion application to Sonarr and Radarr that manages and downloads subtitles"
@@ -1477,6 +1489,7 @@
"switchToLightMode": "Switch to light mode",
"management": "Management",
"docker": "Docker",
"aiChat": "AI Chat",
"preferences": "Your preferences",
"logout": "Logout",
"login": "Login",
@@ -2762,6 +2775,164 @@
"internalServerError": "Failed to fetch Paperless-ngx data"
}
},
"patchmon": {
"name": "PatchMon",
"description": "Overview of host patch status from PatchMon",
"title": "PatchMon Statistics",
"totalHosts": "Total Hosts",
"hostsNeedingUpdates": "Needs Updates",
"securityUpdates": "Security Updates",
"upToDateHosts": "Up-to-Date Hosts",
"hostsWithSecurityUpdates": "Hosts with Security Updates",
"recentUpdates24h": "Updates (24h)",
"totalOutdatedPackages": "Outdated Packages",
"totalRepos": "Repositories",
"osDistribution": "OS Distribution",
"compliance": "Patch Compliance",
"complianceRatio": "{upToDate} / {total}",
"lastUpdated": "Updated {time}",
"option": {
"showTotalHosts": {
"label": "Show total hosts",
"description": "Display the total number of monitored hosts"
},
"showHostsNeedingUpdates": {
"label": "Show hosts needing updates",
"description": "Display hosts with at least one outdated package"
},
"showSecurityUpdates": {
"label": "Show security updates",
"description": "Display the total number of security updates available"
},
"showUpToDateHosts": {
"label": "Show up-to-date hosts",
"description": "Display hosts with zero outdated packages"
},
"showHostsWithSecurityUpdates": {
"label": "Show hosts with security updates",
"description": "Display hosts requiring security patches"
},
"showRecentUpdates24h": {
"label": "Show recent updates (24h)",
"description": "Display the number of updates applied in the last 24 hours"
},
"showTotalOutdatedPackages": {
"label": "Show outdated packages",
"description": "Display the total number of outdated packages across all hosts"
},
"showTotalRepos": {
"label": "Show repositories",
"description": "Display the number of active repositories being monitored"
},
"enableThresholdColors": {
"label": "Enable threshold colors",
"description": "Color stat values green, yellow, or red based on severity presets"
},
"useCustomThresholds": {
"label": "Advanced thresholds",
"description": "Customize warning and critical thresholds per stat using absolute counts or percentages of total hosts"
},
"thresholdMode": {
"option": {
"absolute": "Absolute count",
"percent": "Percent of total hosts"
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "Needs updates threshold mode",
"description": "Compare the needs-updates count as an absolute value or percentage of total hosts"
},
"hostsNeedingUpdatesWarningAt": {
"label": "Needs updates warning threshold",
"description": "Enter yellow when the value reaches this threshold"
},
"hostsNeedingUpdatesCriticalAt": {
"label": "Needs updates critical threshold",
"description": "Enter red when the value reaches this threshold"
},
"securityUpdatesThresholdMode": {
"label": "Security updates threshold mode",
"description": "Compare the security-updates count as an absolute value or percentage of total hosts"
},
"securityUpdatesWarningAt": {
"label": "Security updates warning threshold",
"description": "Enter yellow when the value reaches this threshold"
},
"securityUpdatesCriticalAt": {
"label": "Security updates critical threshold",
"description": "Enter red when the value reaches this threshold"
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "Hosts with security updates threshold mode",
"description": "Compare the host count as an absolute value or percentage of total hosts"
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "Hosts with security updates warning threshold",
"description": "Enter yellow when the value reaches this threshold"
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "Hosts with security updates critical threshold",
"description": "Enter red when the value reaches this threshold"
},
"upToDateHostsThresholdMode": {
"label": "Up-to-date hosts threshold mode",
"description": "Compare the up-to-date host count as an absolute value or percentage of total hosts"
},
"upToDateHostsWarningAt": {
"label": "Up-to-date hosts warning threshold",
"description": "Enter yellow when the value is at or above this threshold but below full coverage"
},
"upToDateHostsCriticalAt": {
"label": "Up-to-date hosts critical threshold",
"description": "Enter red when the value falls below this threshold"
},
"totalOutdatedPackagesThresholdMode": {
"label": "Outdated packages threshold mode",
"description": "Compare the outdated-packages count as an absolute value or percentage of total hosts"
},
"totalOutdatedPackagesWarningAt": {
"label": "Outdated packages warning threshold",
"description": "Enter yellow when the value reaches this threshold"
},
"totalOutdatedPackagesCriticalAt": {
"label": "Outdated packages critical threshold",
"description": "Enter red when the value reaches this threshold"
},
"showOsDistribution": {
"label": "Show OS distribution",
"description": "Display a breakdown of monitored hosts by operating system"
},
"osDistributionLimit": {
"label": "OS distribution limit",
"description": "Maximum number of operating systems to display",
"option": {
"3": "Top 3",
"5": "Top 5",
"10": "Top 10",
"all": "All"
}
},
"showOsVersion": {
"label": "Show OS version",
"description": "Append the OS version to each distribution label when available"
},
"showComplianceHero": {
"label": "Show compliance hero",
"description": "Display a compliance ring showing the percentage of up-to-date hosts"
},
"osDisplayMode": {
"label": "OS display mode",
"description": "Choose how to display the operating system distribution",
"option": {
"bars": "Horizontal bars",
"donut": "Donut chart"
}
}
},
"error": {
"internalServerError": "Failed to fetch PatchMon data"
}
},
"bazarr": {
"name": "Bazarr",
"description": "Overview of missing subtitles and health status from your Bazarr instance",
@@ -3678,6 +3849,61 @@
"executeSuccess": "Action executed successfully",
"executeFailed": "Action failed. Check the widget configuration."
},
"openWebUi": {
"name": "AI Chat",
"description": "Chat with AI models through Open WebUI, with model picking and history.",
"option": {
"systemPrompt": {
"label": "System prompt",
"description": "Optional instruction prepended to every conversation."
},
"showHistory": {
"label": "Show chat history",
"description": "Show a button to browse and reopen previous Open WebUI chats."
}
},
"selectModel": "Select a model",
"searchModel": "Search models…",
"noModels": "No models available",
"emptyState": "Start a conversation",
"messagePlaceholder": "Type a message…",
"newChat": "New chat",
"history": "History",
"back": "Back",
"send": "Send",
"stop": "Stop",
"retry": "Retry",
"visionWarning": "This model may not support images",
"attach": "Attach",
"uploadFiles": "Upload Files",
"capture": "Capture",
"attachImage": "Attach image",
"attachKnowledge": "Knowledge",
"noKnowledge": "No knowledge bases in Open WebUI",
"attachWebpage": "Webpage",
"attachFiles": "Files",
"attachNotes": "Notes",
"referenceChats": "Reference Chats",
"noFiles": "No files in Open WebUI",
"noNotes": "No notes in Open WebUI",
"noChats": "No previous chats",
"addUrl": "Add URL",
"removeAttachment": "Remove attachment",
"webError": "Could not load that web page",
"uploadError": "Could not upload the file",
"captureError": "Could not access the camera",
"chatError": "Could not load that chat",
"noteError": "Could not load that note",
"record": "Record voice message",
"stopRecording": "Stop recording",
"cancelRecording": "Cancel recording",
"micError": "Could not access the microphone",
"micPermissionError": "Microphone access was denied. Allow it in your browser to record voice messages.",
"transcribeError": "Could not transcribe the recording",
"error": {
"internalServerError": "Failed to reach Open WebUI"
}
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
@@ -3861,9 +4087,6 @@
},
"showDockerNetwork": {
"label": "Show Docker network chart"
},
"showStorage": {
"label": "Show storage chart"
}
},
"selectSystem": "Select system",
@@ -3890,8 +4113,8 @@
},
"disk": {
"title": "Disk Usage",
"subtitle": "Usage of root partition",
"series": "Used"
"subtitle": "Usage by filesystem",
"series": "Root"
},
"diskIO": {
"title": "Disk I/O",
@@ -5566,6 +5789,16 @@
"fetch": "An error occurred while fetching data"
},
"mode": {
"askAi": {
"help": "Start an AI chat",
"title": "AI Chat",
"group": {
"ask": {
"startWithQuery": "Ask AI: {query}",
"hint": "Start an AI chat"
}
}
},
"appIntegrationBoard": {
"help": "Search for apps, integrations or boards",
"group": {
+2 -1
View File
@@ -20,7 +20,8 @@
"./errors/classes": "./src/errors/index.ts",
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts"
"./prefetch": "./src/prefetch.ts",
"./_open-webui/chat": "./src/open-webui/chat.tsx"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -27,10 +27,18 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
interface SizeConfig {
iconSize: number;
@@ -106,22 +114,14 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
return Math.max(1, Math.floor(available / size.rowHeight));
};
const MIN_CELL_WIDTH = 120;
const MIN_CELL_WIDTH = 180;
const MIN_CELL_HEIGHT = 80;
const getColCount = (width: number, itemCount: number): number => {
const getColCount = (width: number, height: number, itemCount: number): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
};
interface MetricRowProps {
@@ -133,20 +133,25 @@ interface MetricRowProps {
}
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{value}
</Text>
{progress && (
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
<Progress
value={progress.value}
color={progress.color}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
)}
</Group>
);
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
<Group gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: size.rowHeight }}>
{icon}
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
<Text size={size.fontSize} c="dimmed" w={size.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{label}
</Text>
<MetricValue value={value} progress={progress} size={size} />
@@ -195,14 +200,13 @@ const metricRenderers = [
{
key: "showDisk",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="disk"
icon={<HardDrive size={sz.iconSize} />}
label={t("metric.disk")}
value={formatPercent(s.disk)}
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
size={sz}
/>
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
<HardDrive size={sz.iconSize} />
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("metric.disk")}
</Text>
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
</Group>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
},
@@ -399,7 +403,7 @@ export default function BeszelSystemGridWidget({
);
}
const cols = getColCount(width, filteredSystems.length);
const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -1,54 +1,21 @@
"use client";
import { useCallback, useMemo } from "react";
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDockerChartData,
useEfsChartData,
useSystemChartData,
} from "../beszel/_shared/chart";
import {
chartAxisFormatters,
formatByteRate,
formatGB,
formatPercent,
formatStorageBytes,
} from "../beszel/_shared/format";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { makeTooltipProps } from "../beszel/_shared/tooltip";
import { useLiveStats } from "../beszel/_shared/use-live-stats";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const gridColumns = [1, 2] as const;
function statsWhenShown<T>(show: boolean | undefined, data: T | undefined): T | undefined {
if (!show || !data) return undefined;
return data;
}
import { BeszelStatsView } from "../beszel/_shared/stats-view";
export default function BeszelSystemStatsWidget({
options,
@@ -71,14 +38,12 @@ export default function BeszelSystemStatsWidget({
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label;
const systemExists = systems.some((s) => s.value === selectedSystem);
const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const handleSelectSystem = useCallback(
(value: string) => {
@@ -90,139 +55,13 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const isLive = options.timePeriod === "1m";
const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
systemId: selectedSystem,
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{
refetchInterval: isLive ? false : 5_000,
enabled: !isLive && systemReady && selectedSystem !== "",
},
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
let activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = statsResult;
if (isLive) {
activeStats = liveData;
}
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStatsRaw = activeStats?.containerStats;
const timePeriod = options.timePeriod as BeszelTimePeriod;
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
const dockerCpuData = useDockerChartData(
statsWhenShown(options.showDockerCpu, containerStatsRaw),
containerNames,
"cpu",
timePeriod,
);
const dockerMemData = useDockerChartData(
statsWhenShown(options.showDockerMemory, containerStatsRaw),
containerNames,
"memory",
timePeriod,
);
const dockerNetData = useDockerChartData(
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
containerNames,
"network",
timePeriod,
);
const efsPaths = useMemo(() => {
if (!systemStats) return [];
const paths = new Set<string>();
for (const record of systemStats) {
if (record.stats.efs) {
for (const path of Object.keys(record.stats.efs)) {
paths.add(path);
}
}
}
return [...paths];
}, [systemStats]);
const storageData = useEfsChartData(statsWhenShown(options.showStorage, systemStats), efsPaths, timePeriod);
const containerSeries = useMemo(
() => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
[containerNames],
);
const storageSeries = useMemo(
() => efsPaths.map((path, i) => ({ name: path, color: containerColors[i % containerColors.length] as string })),
[efsPaths],
);
const cols = gridColumns[Number(width > 600)];
if (systemsError) throw systemsError;
if (!isLive && statsError) throw statsError;
if (systemsPending) {
if (systemsPending)
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (systems.length === 0) {
return (
@@ -240,7 +79,7 @@ export default function BeszelSystemStatsWidget({
);
}
if (!systemsPending && !systemExists && systems.length > 0) {
if (!systemExists) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
@@ -260,22 +99,6 @@ export default function BeszelSystemStatsWidget({
);
}
if (isLive && liveError && !liveData) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
<Button size="xs" variant="light" onClick={() => setOptions({ newOptions: { timePeriod: "1h" } })}>
{t("error.switchToHistorical")}
</Button>
</Stack>
</Center>
);
}
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
@@ -284,7 +107,7 @@ export default function BeszelSystemStatsWidget({
<ScrollArea
h="100%"
className={classes.beszelStatsContainer}
style={{ pointerEvents: (isEditMode && "none") || undefined }}
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && (
@@ -300,176 +123,37 @@ export default function BeszelSystemStatsWidget({
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((s) => (
{systems.map((system) => (
<Menu.Item
key={s.value}
key={system.value}
fz="xs"
fw={s.value === selectedSystem ? 600 : 400}
c={s.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(s.value)}
fw={system.value === selectedSystem ? 600 : 400}
c={system.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(system.value)}
>
{s.label}
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
<Center h={CHART_HEIGHT}>
<Loader size="sm" />
</Center>
)}
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
)}
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{options.showMemory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDisk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showNetwork && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showStorage && storageData.length > 0 && storageSeries.length > 0 && (
<BeszelChartPanel
title={t("chart.storage.title")}
subtitle={t("chart.storage.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: storageData,
type: "stacked",
series: storageSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
)}
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDiskIO,
network: options.showNetwork,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
</Stack>
</ScrollArea>
</Box>
@@ -16,6 +16,7 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -45,7 +46,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }),
showStorage: factory.switch({ defaultValue: true }),
}));
},
errors: {
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more