Compare commits

...
Author SHA1 Message Date
ajnart 09260063ae fix(qbittorrent): sort torrents by progress so active items appear before limit cutoff
The qBittorrent integration was passing only { limit } to listTorrents(),
which returns completed torrents first by default. With a 50-item limit and
large completed torrent libraries, active/incomplete torrents never appeared
in the response. The widget's client-side showCompletedTorrent filter then
had nothing to display.

Adding sort: 'progress' (ascending) ensures incomplete/active torrents always
appear first, even with a low limit.

Closes report: https://www.answeroverflow.com/m/1525039765940863046
2026-07-11 20:02:29 +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
Anil K TiwariandThomas 9fb2e37605 feat(widgets): add missing & queued media widget for Radarr and Sonarr (#6078)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:31:08 +02:00
homarr-crowdin[bot] 713fcd6f3f chore(lang): updated translations from crowdin (#6240) 2026-07-10 17:30:10 +02:00
homarr-update-contributors[bot] 6b10cd6874 chore: update integrations list readme (#6258) 2026-07-10 17:29:47 +02:00
homarr-update-contributors[bot] cabc66cc69 chore: update contributors (#6272) 2026-07-10 17:29:30 +02:00
Felix-AyushandThomas 2c5368dd4e feat: add Bazarr integration and subtitle status widget (#6156)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:29:10 +02:00
Nam NguyenandThomas 7d93df04dd fix: add utf8 email validation support (#6239)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:28:22 +02:00
Thomas 7c152cf4fe feat(api): expose board automation endpoints (#6213)
* feat(api): expose board automation endpoints

* style: auto-fix lint and format

* test(api): cover board settings updates

* Delete .opencode/changelog/2026-07-06.md
2026-07-10 17:27:42 +02:00
Thomas 718f095cb7 fix: prevent stale persisted query cache from blocking widget data (#6273) 2026-07-10 17:19:24 +02:00
Thomas 24c5d04bfa fix(beszel): disable request handler cache for stats to make live mode work (#6244) 2026-07-10 17:16:23 +02:00
Thomas 0e513fffd5 feat(widgets): add per-filesystem storage chart to Beszel system stats (#6209) 2026-07-10 17:08:22 +02:00
Thomas 2ada1aa249 fix(qbittorrent): handle v5 stopped torrent states for pause functionality (#6211) 2026-07-10 16:55:20 +02:00
Junker der Provinz af89423f66 fix(docker): report current CPU usage instead of the lifetime average (#6249) 2026-07-10 16:54:52 +02:00
Nerdy TechnicianandThomas 9864fed775 feat: Add Traefik Integration with dedicated widget and Add optional Icons for Gotify/Notifcation Widget (ONLY GOTIFY) (#6206)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 16:49:24 +02:00
Thomas bcd36b1b27 fix(integrations): sort media requests by last modified instead of creation date (#6264) 2026-07-10 16:46:33 +02:00
homarr-renovate[bot] a2f7b35c5c chore(deps): update pnpm to v11.10.0 (#6265)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-09 21:36:24 +00:00
homarr-renovate[bot] 616a444e64 chore(deps): update dependency @immich/sdk to ^3.0.1 (#6251)
* chore(deps): update dependency @immich/sdk to ^3.0.1

* style: auto-fix lint and format

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-09 19:51:55 +00:00
Josh Preston 65ba866239 feat: add Synology integration support for health/system monitoring (#6121) 2026-07-09 18:23:21 +02:00
Thomasandajnart 260b167134 fix: context menu refresh stays open with loading spinner, cache age reacts to refetch
* fix: context menu refresh stays open with loading spinner, cache age reacts to refetch

* style: auto-fix lint and format

* refactor: pass isFetching as prop to WidgetCacheAge to avoid duplicate useIsFetching

---------

Co-authored-by: ajnart <ajnart@users.noreply.github.com>
2026-07-07 18:39:34 +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
198 changed files with 9714 additions and 1016 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
+27
View File
@@ -19,6 +19,14 @@ packages/integrations/src/anchor @ohmzi
apps/docs/docs/integrations/archive-team-warrior @darbyjack
packages/integrations/src/archive-team-warrior @darbyjack
# Bazarr - introduced in https://github.com/homarr-labs/homarr/pull/6156 by @Ayush7614
apps/docs/docs/integrations/bazarr @Ayush7614
apps/docs/docs/widgets/bazarr @Ayush7614
packages/api/src/router/widgets/bazarr.ts @Ayush7614
packages/integrations/src/bazarr @Ayush7614
packages/request-handler/src/bazarr.ts @Ayush7614
packages/widgets/src/bazarr @Ayush7614
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/audiobookshelf @ajnart
packages/integrations/src/audiobookshelf @ajnart
@@ -127,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
@@ -159,6 +174,10 @@ packages/integrations/src/search-ch @Meierschlumpf
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
packages/integrations/src/speedtest-tracker @kylegrieder
# Synology - introduced in https://github.com/homarr-labs/homarr/pull/6121 by @mrjoshuap
apps/docs/docs/integrations/synology @mrjoshuap
packages/integrations/src/synology @mrjoshuap
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
apps/docs/docs/integrations/technitium-dns @lopsch
packages/integrations/src/technitium @lopsch
@@ -187,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# Traefik - by @Nerdy-Technician
apps/docs/docs/integrations/traefik @Nerdy-Technician
apps/docs/docs/widgets/traefik @Nerdy-Technician
packages/api/src/router/widgets/traefik.ts @Nerdy-Technician
packages/integrations/src/traefik @Nerdy-Technician
packages/request-handler/src/traefik.ts @Nerdy-Technician
packages/widgets/src/traefik @Nerdy-Technician
# Exempt Renovate-managed files (no owners)
package.json
**/package.json
@@ -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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,54 @@
---
title: 'Bazarr'
description: "Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles."
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 { bazarrIntegration } from '.';
import { bazarrWidget } from '@site/docs/widgets/bazarr';
<IntegrationHeader
integration={bazarrIntegration}
categories={['Subtitles', 'Media']}
/>
Bazarr complements Sonarr and Radarr by managing subtitle downloads. The Homarr integration uses your Bazarr API key to fetch missing subtitle counts and health badges.
### Screenshots
import apiKeySettings from './img/api-key-settings.png';
import widgetOverview from '@site/docs/widgets/bazarr/img/widget-overview.png';
<div className="flex flex-col gap-4">
<div>
<p>API key location in Bazarr (<strong>Settings → General → Security</strong>):</p>
<img src={apiKeySettings} alt="bazarr-api-key-settings" width={128 * 4} />
</div>
<div>
<p>Bazarr widget on a Homarr board:</p>
<img src={widgetOverview} alt="bazarr-widget-on-board" width={128 * 4} />
</div>
</div>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{ widget: bazarrWidget }]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
You can find your Bazarr API key under **Settings → General → Security**.
<IntegrationSecrets secrets={[{
credentials: ['apiKey'],
steps: [
"Open Bazarr and go to Settings → General → Security.",
"Copy the API key and paste it into Homarr when creating the integration.",
],
}]} />
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const bazarrIntegration: IntegrationDefinition = {
name: "Bazarr",
description:
"Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles for your media library.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
path: "../../integrations/bazarr",
};
@@ -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",
};
@@ -0,0 +1,96 @@
---
title: "Synology DiskStation"
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM."
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 { synologyIntegration } from ".";
import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
import { systemDisksWidget } from "@site/docs/widgets/system-disks";
import { healthMonitoringWidget } from "@site/docs/widgets/health-monitoring";
import Alert from "@theme/Admonition";
<IntegrationHeader integration={synologyIntegration} categories={["NAS", "Hardware"]} />
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: healthMonitoringWidget },
{ widget: systemResourcesWidget },
{ widget: systemDisksWidget },
]}
/>
### Supported metrics
| Widget section | DSM API source | May be unavailable |
| ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
| CPU ring / chart | `SYNO.Core.System.Utilization` | — |
| Memory ring / chart | `SYNO.Core.System.Utilization` | — |
| CPU temperature ring | `SYNO.Core.System` system info | Models without a temperature sensor |
| Network charts | `SYNO.Core.System.Utilization` network totals | Some DSM builds or permission configurations |
| Load average (info modal) | `SYNO.Core.System.Utilization` CPU load fields | — |
| Storage volume usage | `SYNO.Core.System` storage info / Storage Manager API | — |
| SMART status and temperature | Storage Manager and SMART APIs | Disk-to-volume mapping may be incomplete on some setups |
| Reboot / update indicator | `SYNO.Core.System.Status` / upgrade APIs | Permission-dependent |
| GPU | Not available | Typical DiskStation models |
### Adding the integration
<AddingIntegration />
<Alert type="info" title="Use the correct DSM URL">
Enter the HTTP or HTTPS URL of your Synology DSM web interface, for example <code>http://diskstation.local:5000</code>{" "}
or <code>https://diskstation.local:5001</code>. If DSM uses a self-signed certificate, add it under{" "}
<a href="/docs/management/certificates">Management &gt; Tools &gt; Certificates</a> so Homarr can connect securely.
</Alert>
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: ["username", "password"],
steps: [
"Create a dedicated DSM user with a strong password for Homarr.",
"Add the user to the default Administrators group so Homarr can read `SYNO.Core.System` metrics.",
"Under User Groups and Permissions, deny access to shared folders and most applications.",
"Allow login to DSM only, optionally restricted by source IP address.",
"Log in once with the service account in DSM to accept terms and conditions.",
],
body: (
<>
<Alert type="caution" title="Administrators group required">
Homarr reads system, storage, and utilization data from APIs that require membership in the default
Administrators group.
</Alert>
<Alert type="warning" title="Two-factor authentication is not supported">
Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service
account without 2FA.
</Alert>
<Alert type="tip" title="First-time setup">
If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel
&gt; Security &gt; Protection, then re-enable it after Homarr connects successfully.
</Alert>
</>
),
},
]}
/>
### Storage volume selection
Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name.
<Alert type="note" title="Synology-only option">
The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid.
</Alert>
<Alert type="note" title="Volume ordering">
When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`.
</Alert>
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const synologyIntegration: IntegrationDefinition = {
name: "Synology DiskStation",
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg",
path: "../../integrations/synology",
};
@@ -0,0 +1,57 @@
---
title: "Traefik"
description: "Cloud-native reverse proxy and load balancer"
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 { traefikIntegration } from ".";
import { traefikWidget } from "../../widgets/traefik";
<IntegrationHeader integration={traefikIntegration} categories={["Reverse proxy", "Monitoring"]} />
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: traefikWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the URL where Traefik exposes its dashboard/API, for example `http://traefik:8080` or `https://traefik.example.com`. Homarr calls Traefik endpoints under `/api` automatically.
:::
:::warning API requirement
Traefik must have the API enabled. For local or internal setups this is commonly exposed through `api.insecure=true`; for production setups, expose the `api@internal` service through an authenticated router.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [
"Choose this option when the Traefik API is only reachable from a trusted internal network and does not require authentication.",
],
},
{
credentials: ["username", "password"],
steps: [
"Use this option when your Traefik dashboard route is protected by Basic Auth.",
"Enter the same username and password required to open the Traefik dashboard.",
],
},
{
credentials: ["apiKey"],
steps: [
"Use this option when your Traefik dashboard route accepts a bearer token.",
"Paste the token into the API Key field in Homarr.",
],
},
]}
/>
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const traefikIntegration: IntegrationDefinition = {
name: "Traefik",
description: "Cloud-native reverse proxy and load balancer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
path: "../../integrations/traefik",
};
+13 -1
View File
@@ -70,4 +70,16 @@ API key authentication enables programmatic access for:
- Automation scripts and CI/CD pipelines
- Third-party integrations
- Custom dashboards and monitoring tools
- Container orchestration systems that need to sync app definitions
- Container orchestration systems that need to sync app definitions
## Board Automation
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
- Create, duplicate, rename and delete boards
- Change board visibility
- Update board visual and behavior settings
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
+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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+41
View File
@@ -0,0 +1,41 @@
---
title: 'Bazarr'
description: 'Displays missing subtitle counts and health indicators from your Bazarr instance.'
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 { bazarrWidget } from '.';
import { bazarrIntegration } from '@site/docs/integrations/bazarr';
<WidgetHeader
widget={bazarrWidget}
categories={['Subtitles', 'Media']}
/>
This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings.
### Screenshots
import widgetOverview from './img/widget-overview.png';
<div className="flex flex-col gap-2">
<img src={widgetOverview} alt="bazarr-widget-overview" width={128 * 4} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: bazarrIntegration,
note: "Displays missing subtitle counts and health indicators"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={bazarrWidget.configuration} />
+37
View File
@@ -0,0 +1,37 @@
import { WidgetDefinition } from "@site/src/types";
import { IconBadgeCc } from "@tabler/icons-react";
export const bazarrWidget: WidgetDefinition = {
icon: IconBadgeCc,
name: "Bazarr",
description: "Displays missing subtitle counts and health indicators from your Bazarr instance.",
path: "../../widgets/bazarr",
configuration: {
items: [
{
name: "Show missing episodes",
description: "Displays the number of episodes with missing subtitles",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show missing movies",
description: "Displays the number of movies with missing subtitles",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show provider issues",
description: "Displays the number of subtitle providers with issues",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show health issues",
description: "Displays the number of Bazarr health issues",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -1,61 +1,71 @@
---
title: 'System Health Monitoring'
description: 'Displays information showing the health and status of your system(s).'
title: "System Health Monitoring"
description: "Displays information showing the health and status of your system(s)."
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 { 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 { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { healthMonitoringWidget } from '.';
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
import { glancesIntegration } from '@site/docs/integrations/glances';
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { healthMonitoringWidget } from ".";
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
import { glancesIntegration } from "@site/docs/integrations/glances";
<WidgetHeader
widget={healthMonitoringWidget}
categories={['Health monitoring']}
/>
<WidgetHeader widget={healthMonitoringWidget} categories={["Health monitoring"]} />
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
### Screenshots
import systemView from './img/system-view.png';
import clusterView from './img/cluster-view.png';
import { truenasIntegration } from '@site/docs/integrations/truenas';
import systemView from "./img/system-view.png";
import clusterView from "./img/cluster-view.png";
import { truenasIntegration } from "@site/docs/integrations/truenas";
import { synologyIntegration } from "@site/docs/integrations/synology";
<img src={systemView} alt="health-monitoring system-view" width={256} height={256} />
<img src={clusterView} alt="health-monitoring cluster-view" width={256} height={256} />
### Supported Integrations
<WidgetIntegrations items={[{
integration: dashDotIntegration,
note: "Will display information on the system tab"
}, {
integration: openMediaVaultIntegration,
note: "Will display information on the system tab"
}, {
integration: truenasIntegration,
note: "Will display information on the system tab"
}, {
integration: glancesIntegration,
note: "Will display information on the system tab"
}, {
integration: proxmoxIntegration,
note: "Will display information on the cluster tab, only supports one integration at a time"
}]} />
<WidgetIntegrations
items={[
{
integration: dashDotIntegration,
note: "Will display information on the system tab",
},
{
integration: openMediaVaultIntegration,
note: "Will display information on the system tab",
},
{
integration: truenasIntegration,
note: "Will display information on the system tab",
},
{
integration: synologyIntegration,
note: "Will display information on the system tab",
},
{
integration: glancesIntegration,
note: "Will display information on the system tab",
},
{
integration: proxmoxIntegration,
note: "Will display information on the cluster tab, only supports one integration at a time",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={healthMonitoringWidget.configuration} />
@@ -38,6 +38,13 @@ export const healthMonitoringWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Visible storage volumes",
description:
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
values: "List of storage volumes",
defaultValue: "Empty list",
},
{
name: "Default tab",
description: "Tab that should be visible by default. Only used when multiple integrations are available.",
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

@@ -0,0 +1,65 @@
---
title: 'Media Missing & Queue'
description: 'Track missing media and active downloads from Radarr and Sonarr'
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 { mediaMissingWidget } from '.';
import { radarrIntegration } from '@site/docs/integrations/radarr';
import { sonarrIntegration } from '@site/docs/integrations/sonarr';
<WidgetHeader
widget={mediaMissingWidget}
categories={['Media', 'Downloads']}
/>
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
### Screenshots
import widgetMissing from './img/missing.png';
import widgetQueued from './img/queued.png';
import widgetCompact from './img/compact.png';
<div className="flex gap-2 flex-wrap">
<img src={widgetMissing} alt="media-missing widget showing missing movies and episodes in a grid" width={360} />
<img src={widgetQueued} alt="media-missing tab showing download queue with progress bars" width={256} />
<img src={widgetCompact} alt="media-missing widget in a compact wide-and-short size" width={256} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: radarrIntegration,
}, {
integration: sonarrIntegration,
}]} />
### Tabs
| Tab | Description |
|---|---|
| **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. |
| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. |
### Sizing
The widget adapts to its container size, so it looks good at any grid size:
- **Wide** — fits as many columns of cards as the width allows, filling larger widgets instead of stretching a single column.
- **Short** — packs items into more, tighter columns so they stay visible in the limited height.
- **Narrow (1×N)** — tab labels collapse to counts only and posters scale down.
Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining.
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaMissingWidget.configuration} />
@@ -0,0 +1,31 @@
import { WidgetDefinition } from "@site/src/types";
import { IconMovie } from "@tabler/icons-react";
export const mediaMissingWidget: WidgetDefinition = {
icon: IconMovie,
name: "Media Missing & Queue",
description: "Track missing media and active downloads from Radarr and Sonarr",
path: "../../widgets/media-missing",
configuration: {
items: [
{
name: "Show Missing",
description: "Display the missing media tab, listing movies or episodes not yet downloaded",
values: { type: "boolean" },
defaultValue: "true",
},
{
name: "Show Queued",
description: "Display the download queue tab with progress for each item",
values: { type: "boolean" },
defaultValue: "true",
},
{
name: "Page Size",
description: "Maximum number of items to fetch per integration",
values: { type: "string" },
defaultValue: "10",
},
],
},
};
+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",
},
],
},
};
@@ -0,0 +1,51 @@
---
title: "System disks"
description: "Disk usage of your system"
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 { systemDisksWidget } from ".";
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
import { truenasIntegration } from "@site/docs/integrations/truenas";
import { unraidIntegration } from "@site/docs/integrations/unraid";
import { synologyIntegration } from "@site/docs/integrations/synology";
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: dashDotIntegration,
},
{
integration: openMediaVaultIntegration,
},
{
integration: truenasIntegration,
},
{
integration: unraidIntegration,
},
{
integration: synologyIntegration,
note: "Supports storage volume filtering when only Synology integrations are selected",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={systemDisksWidget.configuration} />
@@ -0,0 +1,41 @@
import { WidgetDefinition } from "@site/src/types";
import { IconServer2 } from "@tabler/icons-react";
export const systemDisksWidget: WidgetDefinition = {
icon: IconServer2,
name: "System disks",
description: "Disk usage of your system",
path: "../../widgets/system-disks",
configuration: {
items: [
{
name: "Show temperature if available",
description: "Display disk temperature when the integration provides it",
values: { type: "boolean" },
defaultValue: "Yes",
},
{
name: "Display mode",
description: "How disk usage values are shown",
values: {
type: "select",
options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"],
},
defaultValue: "Percentage (e.g., 76%)",
},
{
name: "Show background usage bar",
description: "Fill the area below the usage value with a background bar",
values: { type: "boolean" },
defaultValue: "Yes",
},
{
name: "Visible storage volumes",
description:
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
values: "List of storage volumes",
defaultValue: "All volumes",
},
],
},
};
@@ -1,57 +1,69 @@
---
title: 'System Resources'
description: 'Displays CPU, RAM and network of your host in real time'
title: "System Resources"
description: "Displays CPU, RAM and network of your host in real time"
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 { systemResourcesWidget } from '.';
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
import { glancesIntegration } from '@site/docs/integrations/glances';
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
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 { systemResourcesWidget } from ".";
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
import { glancesIntegration } from "@site/docs/integrations/glances";
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
<WidgetHeader
widget={systemResourcesWidget}
categories={['CPU', 'RAM', 'Memory', 'Network', 'Interface']}
/>
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
Displays CPU, RAM and network of your host in real time
### Screenshots
import network from './img/network.png';
import cpu from './img/cpu.png';
import memory from './img/memory.png';
import cpuAndMemory from './img/cpu-and-memory.png';
import { truenasIntegration } from '@site/docs/integrations/truenas';
import network from "./img/network.png";
import cpu from "./img/cpu.png";
import memory from "./img/memory.png";
import cpuAndMemory from "./img/cpu-and-memory.png";
import { truenasIntegration } from "@site/docs/integrations/truenas";
import { synologyIntegration } from "@site/docs/integrations/synology";
<div className='flex gap-1'>
<div className='flex flex-col gap-1'>
<div className="flex gap-1">
<div className="flex flex-col gap-1">
<img src={network} alt="system-resources-widget network-only icon-label" width={256 + 4} height={128} />
<div className='flex gap-1'>
<div className="flex gap-1">
<img src={cpu} alt="system-resources-widget cpu-only no-label item-title" width={128} height={128} />
<img src={memory} alt="system-resources-widget memory-only text-label" width={128} height={128} />
</div>
</div>
<img src={cpuAndMemory} alt="system-resources-widget cpu-and-memory icon-text-label" width={256 + 4} height={256 + 4} />
<img
src={cpuAndMemory}
alt="system-resources-widget cpu-and-memory icon-text-label"
width={256 + 4}
height={256 + 4}
/>
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: dashDotIntegration
}, {
integration: openMediaVaultIntegration
}, {
integration: truenasIntegration
}, {
integration: glancesIntegration
}]} />
<WidgetIntegrations
items={[
{
integration: dashDotIntegration,
},
{
integration: openMediaVaultIntegration,
},
{
integration: truenasIntegration,
},
{
integration: synologyIntegration,
},
{
integration: glancesIntegration,
},
]}
/>
### Adding the widget
@@ -59,4 +71,4 @@ import { truenasIntegration } from '@site/docs/integrations/truenas';
### Configuration
<WidgetConfig configuration={systemResourcesWidget.configuration} />
<WidgetConfig configuration={systemResourcesWidget.configuration} />
+35
View File
@@ -0,0 +1,35 @@
---
title: "Traefik"
description: "Displays Traefik router, service, middleware, and entry point 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 { traefikWidget } from ".";
import { traefikIntegration } from "@site/docs/integrations/traefik";
<WidgetHeader widget={traefikWidget} categories={["Monitoring", "Reverse proxy"]} />
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: traefikIntegration,
note: "Reads data from Traefik's dashboard/API endpoints",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={traefikWidget.configuration} />
+31
View File
@@ -0,0 +1,31 @@
import { WidgetDefinition } from "@site/src/types";
import { IconRoute } from "@tabler/icons-react";
export const traefikWidget: WidgetDefinition = {
icon: IconRoute,
name: "Traefik",
description: "Overview of Traefik routers, services, middlewares, and entry points.",
path: "../../widgets/traefik",
configuration: {
items: [
{
name: "Show TCP",
description: "Display TCP routers, services, and middlewares.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show UDP",
description: "Display UDP routers and services.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show entry points",
description: "Display the configured Traefik entry point names.",
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",
+1 -1
View File
@@ -1 +1 @@
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.0.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.1.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
@@ -79,14 +79,16 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const userId = session?.user.id ?? "anonymous";
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
<>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
</>
);
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
+1 -1
View File
@@ -142,7 +142,7 @@ export default async function Layout(props: {
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
@@ -26,6 +26,8 @@ export const integrationSecretIcons = {
url: IconLink,
opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword,
patchmonApiKey: IconKey,
patchmonApiSecret: IconPassword,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
@@ -17,6 +17,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { optionalEmailSchema } from "@homarr/validation/email";
import { userPasswordSchema } from "@homarr/validation/user";
import { GroupSelectModal } from "~/components/access/group-select-modal";
@@ -60,7 +61,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
const generalForm = useZodForm(
z.object({
username: z.string().min(1),
email: z.string().email().or(z.string().length(0).optional()),
email: optionalEmailSchema,
}),
{
initialValues: {
@@ -30,7 +30,19 @@ export const InheritAccessTable = <TPermission extends string>({
</TableTr>
</TableThead>
<TableTbody>
{accessQueryData.inherited.map(({ group, permission }) => {
{Array.from(
accessQueryData.inherited
.reduce((map, entry) => {
const existing = map.get(entry.group.id);
if (!existing) {
map.set(entry.group.id, entry);
} else if (getPermissionsWithChildren([entry.permission]).includes(existing.permission)) {
map.set(entry.group.id, entry);
}
return map;
}, new Map<string, (typeof accessQueryData.inherited)[number]>())
.values(),
).map(({ group, permission }) => {
const entityPermission =
permission in mapPermissions
? mapPermissions[permission]
@@ -2,10 +2,10 @@
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Menu, Switch, Text } from "@mantine/core";
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 { useQueryClient } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -50,8 +50,9 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const queryClient = useQueryClient();
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: widgetQueryKey });
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
}, [queryClient, widgetQueryKey]);
const options = useMemo(
@@ -223,11 +224,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Item closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap">
{tMenu("refresh")}
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} />
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
</Text>
</Group>
</Menu.Item>
@@ -265,13 +270,24 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
};
const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient;
queryKey: unknown[];
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
if (isFetching) return <Loader size={12} />;
const timestamps = queryClient
.getQueryCache()
.findAll({ queryKey })
@@ -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")}
/>
),
},
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"pnpm": ">=11.10.0"
},
"packageManager": "pnpm@11.9.0"
"packageManager": "pnpm@11.10.0"
}
+50 -22
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,6 +352,13 @@
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</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/>
<p align="center">Synology<br/>DiskStation</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
@@ -352,8 +373,8 @@
<br/>
<p align="center">Technitium<br/>DNS</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
<br/>
@@ -361,6 +382,13 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/traefik" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg" alt="Traefik" width="90" height="90" />
<br/>
<p align="center">Traefik</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/transmission" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg" alt="Transmission" width="90" height="90" />
<br/>
@@ -373,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/>
+3 -4
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:",
@@ -63,9 +62,9 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"pnpm": ">=11.10.0"
},
"packageManager": "pnpm@11.9.0",
"packageManager": "pnpm@11.10.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
+6
View File
@@ -9,6 +9,8 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
@@ -16,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";
@@ -28,6 +31,8 @@ export const mcpRouter = createTRPCRouter({
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
bazarr: bazarrRouter,
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
dnsHole: dnsHoleRouter,
@@ -35,6 +40,7 @@ export const mcpRouter = createTRPCRouter({
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
patchmon: patchmonRouter,
smartHome: smartHomeRouter,
widgetSecrets: widgetSecretsRouter,
});
+3 -1
View File
@@ -6,6 +6,7 @@ import { appRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { infoRouter } from "./router/info";
import { inviteRouter } from "./router/invite";
import { serverSettingsRouter } from "./router/serverSettings";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";
@@ -14,13 +15,14 @@ export const openApiRouter = createTRPCRouter({
boardRouter,
infoRouter,
inviteRouter,
serverSettingsRouter,
userRouter,
});
export const openApiDocument = (base: string) =>
generateOpenApiDocument(openApiRouter, {
title: "Homarr API documentation",
version: "1.0.0",
version: "1.1.0",
baseUrl: base,
docsUrl: "https://homarr.dev",
securitySchemes: {
+113 -36
View File
@@ -35,6 +35,7 @@ import {
everyoneGroup,
getPermissionsWithChildren,
getPermissionsWithParents,
widgetDefaultSizes,
widgetKinds,
} from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import";
@@ -330,7 +331,16 @@ export const boardRouter = createTRPCRouter({
}),
duplicateBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.meta({
openapi: { method: "POST", path: "/api/boards/{id}/duplicate", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
},
})
.input(boardDuplicateSchema)
.output(z.object({ boardId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await noBoardWithSimilarNameAsync(ctx.db, input.name);
@@ -544,33 +554,57 @@ export const boardRouter = createTRPCRouter({
});
},
});
return { boardId: newBoardId };
}),
renameBoard: protectedProcedure.input(boardRenameSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
renameBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/name", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Rename a board by ID. Requires full permission on the board. REQUIRED: id (board ID), name (new unique board name)",
},
})
.input(boardRenameSchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
}),
changeBoardVisibility: protectedProcedure.input(boardChangeVisibilitySchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
}),
changeBoardVisibility: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/visibility", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Change board visibility. Requires full permission on the board. REQUIRED: id (board ID), visibility ('public' or 'private'). Home boards cannot be made private",
},
})
.input(boardChangeVisibilitySchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
});
}
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
});
}
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
deleteBoard: protectedProcedure
.meta({
openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true },
@@ -587,16 +621,38 @@ export const boardRouter = createTRPCRouter({
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
setHomeBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/home", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Set the current user's desktop home board. Requires view permission on the board. REQUIRED: id (board ID)",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/mobile-home", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Set the current user's mobile home board. Requires view permission on the board. REQUIRED: id (board ID)",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const user = userId
@@ -752,7 +808,16 @@ export const boardRouter = createTRPCRouter({
}
}),
savePartialBoardSettings: protectedProcedure
.input(boardSavePartialSettingsSchema.and(z.object({ id: z.string() })))
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Update visual and behavior settings for a board. Requires modify permission. REQUIRED: id (board ID). Optional fields include pageTitle, metaTitle, logoImageUrl, faviconImageUrl, backgroundImageUrl, colors, opacity, customCss, itemRadius, and disableStatus",
},
})
.input(boardSavePartialSettingsSchema.extend({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
@@ -1438,21 +1503,33 @@ export const boardRouter = createTRPCRouter({
}
}
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const fitsAt = (x: number, y: number) => {
if (x + defaultSize.width > layout.columnCount) return false;
for (let dy = 0; dy < defaultSize.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
for (let dx = 0; dx < defaultSize.width; dx++) {
if (row[x + dx]) return false;
}
}
return true;
};
let placed = false;
for (let y = 0; y < 9999 && !placed; y++) {
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
const occupiedRow = occupied[y];
if (!occupiedRow) continue;
for (let x = 0; x < layout.columnCount && !placed; x++) {
if (!occupiedRow[x]) {
if (fitsAt(x, y)) {
layoutRows.push({
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: 1,
height: 1,
width: defaultSize.width,
height: defaultSize.height,
});
placed = true;
}
+79 -1
View File
@@ -1,6 +1,14 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { and, eq, inArray } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "@homarr/db/queries";
import { boards, serverSettings } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings";
@@ -8,6 +16,15 @@ import { settingsInitSchema } from "@homarr/validation/settings";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
const boardServerSettingsSchema = z.object({
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
}) satisfies z.ZodType<ServerSettings["board"]>;
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "culture");
@@ -15,6 +32,67 @@ export const serverSettingsRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await getServerSettingsAsync(ctx.db);
}),
getBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: { method: "GET", path: "/api/settings/board", tags: ["settings"], protect: true },
mcp: {
enabled: true,
description:
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
},
})
.input(z.void())
.output(boardServerSettingsSchema)
.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "board");
}),
updateBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: { method: "PATCH", path: "/api/settings/board", tags: ["settings"], protect: true },
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
.output(boardServerSettingsSchema)
.mutation(async ({ ctx, input }) => {
const inputBoardIds = [input.homeBoardId, input.mobileHomeBoardId].filter(
(id) => id !== undefined && id !== null,
);
if (inputBoardIds.length > 0) {
const publicBoards = await ctx.db.query.boards.findMany({
columns: { id: true },
where: and(inArray(boards.id, inputBoardIds), eq(boards.isPublic, true)),
});
const publicBoardIds = new Set(publicBoards.map((board) => board.id));
const invalidBoardIds = inputBoardIds.filter((id) => !publicBoardIds.has(id));
if (invalidBoardIds.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Board settings home board IDs must reference public boards: ${invalidBoardIds.join(", ")}`,
});
}
}
const current = await getServerSettingByKeyAsync(ctx.db, "board");
const next = { ...current, ...input };
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "board"),
});
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, "board", next);
} else {
await insertServerSettingByKeyAsync(ctx.db, "board", next);
}
return next;
}),
saveSettings: permissionRequiredProcedure
.requiresPermission("admin")
.input(
@@ -1,9 +1,9 @@
import SuperJSON from "superjson";
import { stringify } from "superjson";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { serverSettings } from "@homarr/db/schema";
import { boards, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -33,7 +33,7 @@ describe("getAll server settings", () => {
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
value: stringify(defaultServerSettings.analytics),
},
]);
@@ -67,7 +67,7 @@ describe("saveSettings", () => {
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
value: stringify(defaultServerSettings.analytics),
},
]);
@@ -82,7 +82,7 @@ describe("saveSettings", () => {
expect(dbSettings).toStrictEqual([
{
settingKey: "analytics",
value: SuperJSON.stringify({
value: stringify({
enableGeneral: true,
instanceId: null,
}),
@@ -90,3 +90,91 @@ describe("saveSettings", () => {
]);
});
});
describe("board settings API", () => {
test("getBoardSettings should return board defaults", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
const result = await caller.updateBoardSettings({
homeBoardId: boardId,
forceDisableStatus: true,
});
expect(result).toStrictEqual({
...defaultServerSettings.board,
homeBoardId: boardId,
forceDisableStatus: true,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
},
]);
});
test("updateBoardSettings should update existing settings", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
enableStatusByDefault: false,
});
expect(result).toStrictEqual({
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
},
]);
});
test("updateBoardSettings should reject private home boards", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "private", isPublic: false });
const actAsync = async () => await caller.updateBoardSettings({ homeBoardId: boardId });
await expect(actAsync()).rejects.toThrow("must reference public boards");
});
});
@@ -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",
});
});
});
+21
View File
@@ -0,0 +1,21 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const bazarrRouter = createTRPCRouter({
getBadges: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
},
})
.concat(createOneIntegrationMiddleware("query", "bazarr"))
.query(async ({ ctx }) => {
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
});
+100 -42
View File
@@ -1,18 +1,24 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { LiveStatsEvent } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
beszelStatsRequestHandler,
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "beszelRouter" });
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -26,8 +32,9 @@ export const beszelRouter = createTRPCRouter({
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
@@ -37,25 +44,18 @@ export const beszelRouter = createTRPCRouter({
systems: data,
updatedAt: timestamp,
};
}),
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: [],
updatedAt: new Date(0),
error: errorMessage(error),
}),
},
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getSystems integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
integrationUrl: integration?.url ?? "",
systems: [],
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getSystems completed", {
userId: ctx.session?.user?.id,
integrationIds,
@@ -88,8 +88,9 @@ export const beszelRouter = createTRPCRouter({
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
@@ -115,26 +116,19 @@ export const beszelRouter = createTRPCRouter({
systemNameMap,
updatedAt: alertsResult.timestamp,
};
}),
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
alerts: [],
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: errorMessage(error),
}),
},
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getAlerts integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
alerts: [],
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getAlerts completed", {
userId: ctx.session?.user?.id,
integrationIds,
@@ -197,8 +191,72 @@ export const beszelRouter = createTRPCRouter({
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: error instanceof Error ? error.message : String(error),
error: errorMessage(error),
};
}
}),
subscribeSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
systemId: z.string(),
}),
)
.subscription(({ ctx, input }) => {
return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController();
let isActive = true;
void (async () => {
const integration = ctx.integrations[0];
if (!integration) {
emit.error(
new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }),
);
return;
}
try {
const instance = await createIntegrationAsync(integration);
if (typeof instance.subscribeRealtimeMetrics === "function") {
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
if (isActive) emit.next(event);
},
controller.signal,
);
} else {
emit.error(
new TRPCError({
code: "METHOD_NOT_SUPPORTED",
message: `Integration ${integration.kind} does not support realtime metrics`,
}),
);
}
} catch (error) {
if (isActive) {
emit.error(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
);
}
}
if (isActive) {
emit.complete();
}
})();
return () => {
isActive = false;
controller.abort();
};
});
}),
});
@@ -49,6 +49,7 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.pauseQueueAsync();
}),
);
downloadClientRequestHandler.invalidateCache();
}),
pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
@@ -60,6 +61,7 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.pauseItemAsync(input.item);
}),
);
downloadClientRequestHandler.invalidateCache();
}),
resume: protectedProcedure
.meta({
@@ -77,6 +79,7 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.resumeQueueAsync();
}),
);
downloadClientRequestHandler.invalidateCache();
}),
resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
@@ -88,6 +91,7 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.resumeItemAsync(input.item);
}),
);
downloadClientRequestHandler.invalidateCache();
}),
deleteItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
@@ -99,5 +103,6 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
);
downloadClientRequestHandler.invalidateCache();
}),
});
@@ -1,21 +1,31 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const healthMonitoringIntegrationKinds = [
"openmediavault",
"dashDot",
"truenas",
"unraid",
"glances",
"synology",
"mock",
] as const;
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
},
})
.concat(
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
@@ -27,6 +37,23 @@ export const healthMonitoringRouter = createTRPCRouter({
};
});
}),
listStorageVolumes: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
},
})
.concat(createOneIntegrationMiddleware("query", "synology"))
.query(async ({ ctx }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
if (!(integrationInstance instanceof SynologyIntegration)) {
throw new Error("Expected Synology integration");
}
return await integrationInstance.listStorageVolumesAsync();
}),
getClusterHealthStatus: publicProcedure
.meta({
mcp: {
+4
View File
@@ -9,6 +9,8 @@ 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)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
@@ -20,6 +22,7 @@ export const widgetRouter = createTRPCRouter({
calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)),
downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)),
mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)),
mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)),
rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)),
indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)),
healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)),
@@ -38,6 +41,7 @@ export const widgetRouter = createTRPCRouter({
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
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)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
@@ -0,0 +1,33 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({
getData: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
const { data } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
...data,
};
}),
);
return results;
}),
});
@@ -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();
@@ -0,0 +1,31 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const traefikRouter = createTRPCRouter({
getDashboard: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
},
})
.concat(createManyIntegrationMiddleware("query", "traefik"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = traefikRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
}),
});
+6
View File
@@ -9,9 +9,14 @@ interface IntegrationLike {
kind: string;
}
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
}
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<TResult[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
return settled.flatMap((result, index) => {
@@ -24,6 +29,7 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
{ cause: result.reason },
),
);
if (options?.fallback && integration) return [options.fallback(integration, result.reason)];
return [];
});
}
+8
View File
@@ -2,16 +2,20 @@ import { expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { appRouter } from "../router/app";
import { boardRouter } from "../router/board";
import { infoRouter } from "../router/info";
import { inviteRouter } from "../router/invite";
import { serverSettingsRouter } from "../router/serverSettings";
import { createTRPCRouter } from "../trpc";
vi.mock("@homarr/auth", () => ({}));
const mcpTestRouter = createTRPCRouter({
app: appRouter,
board: boardRouter,
info: infoRouter,
invite: inviteRouter,
serverSettings: serverSettingsRouter,
});
test("MCP tools should be extractable from the router", () => {
@@ -28,8 +32,12 @@ test("MCP tools should contain expected procedures", () => {
expect(toolNames).toContain("app_all");
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("board_savePartialBoardSettings");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");
expect(toolNames).toContain("serverSettings_getBoardSettings");
expect(toolNames).toContain("serverSettings_updateBoardSettings");
});
test("MCP tools should have descriptions", () => {
+9
View File
@@ -14,3 +14,12 @@ test("OpenAPI documentation should be generated", () => {
// Assert
expect(act).not.toThrow();
});
test("OpenAPI documentation should expose board automation endpoints", () => {
const document = openApiDocument("https://homarr.dev");
expect(document.info.version).toBe("1.1.0");
expect(document.paths).toHaveProperty("/api/boards/{id}/settings");
expect(document.paths).toHaveProperty("/api/boards/{id}/duplicate");
expect(document.paths).toHaveProperty("/api/settings/board");
});
@@ -1,5 +1,5 @@
import { CredentialsSignin } from "@auth/core/errors";
import { z } from "zod/v4";
import type { z } from "zod/v4";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -7,6 +7,7 @@ import type { Database, InferInsertModel } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
import type { ldapSignInSchema } from "@homarr/validation/user";
import { utf8EmailSchema } from "@homarr/validation/email";
import { env } from "../../../env";
import { LdapClient } from "../ldap-client";
@@ -52,8 +53,7 @@ export const authorizeWithLdapCredentialsAsync = async (
throw new CredentialsSignin(`User not found in LDAP username="${credentials.name}"`);
}
// Validate email
const mailResult = await z.string().email().safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
const mailResult = await utf8EmailSchema.safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
if (!mailResult.success) {
logger.error("User found in LDAP but with invalid or non-existing Email", {
@@ -295,13 +295,6 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
}
]
}
@@ -57,13 +57,6 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
}
]
}
@@ -281,13 +281,6 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
}
]
}
@@ -31,8 +31,11 @@ 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"],
synology: ["synology", "diskstation"],
};
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
@@ -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"
@@ -196,9 +198,11 @@ export type HomarrDocumentationPath =
| "/docs/integrations/slskd"
| "/docs/integrations/sonarr"
| "/docs/integrations/speedtest-tracker"
| "/docs/integrations/synology"
| "/docs/integrations/tdarr"
| "/docs/integrations/technitium-dns"
| "/docs/integrations/tracearr"
| "/docs/integrations/traefik"
| "/docs/integrations/transmission"
| "/docs/integrations/truenas"
| "/docs/integrations/umami"
@@ -221,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"
@@ -240,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"
@@ -251,15 +257,18 @@ 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"
| "/docs/widgets/smart-home-execute-automation"
| "/docs/widgets/speedtest-tracker"
| "/docs/widgets/stock-price"
| "/docs/widgets/system-disks"
| "/docs/widgets/system-resources"
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/traefik"
| "/docs/widgets/umami"
| "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma"
@@ -280,6 +289,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/lidarr"
| "/docs/integrations/readarr"
| "/docs/integrations/prowlarr"
| "/docs/integrations/bazarr"
| "/docs/integrations/jellyfin"
| "/docs/integrations/emby"
| "/docs/integrations/plex"
@@ -303,10 +313,12 @@ export type HomarrDocumentationPath =
| "/docs/integrations/ical"
| "/docs/integrations/anchor"
| "/docs/integrations/truenas"
| "/docs/integrations/synology"
| "/docs/integrations/unraid"
| "/docs/integrations/coolify"
| "/docs/integrations/immich"
| "/docs/integrations/paperless-ngx"
| "/docs/integrations/patchmon"
| "/docs/integrations/tracearr"
| "/docs/integrations/speedtest-tracker"
| "/docs/integrations/uptime-kuma"
@@ -316,6 +328,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/traefik"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/app"
@@ -334,6 +347,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"
@@ -348,10 +362,13 @@ export type HomarrDocumentationPath =
| "/docs/widgets/notifications"
| "/docs/widgets/system-resources"
| "/docs/widgets/coolify"
| "/docs/widgets/system-disks"
| "/docs/widgets/timetable"
| "/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"
| "/docs/widgets/uptime-kuma"
@@ -363,4 +380,5 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-system-grid"
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api";
@@ -13,6 +13,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
lidarr: "lidarr",
readarr: "readarr",
prowlarr: "prowlarr",
bazarr: "bazarr",
jellyfin: "jellyfin",
emby: "emby",
plex: "plex",
@@ -36,10 +37,12 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
ical: "ical",
anchor: "anchor",
truenas: "truenas",
synology: "synology",
unraid: "unraid",
coolify: "coolify",
immich: "immich",
paperlessNgx: "paperless-ngx",
patchmon: "patchmon",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
uptimeKuma: "uptime-kuma",
@@ -49,6 +52,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
mock: null,
};
@@ -19,6 +19,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
"mediaRequests-requestList": "media-request-list",
"mediaRequests-requestStats": "media-request-stats",
mediaTranscoding: "media-transcoding",
mediaMissing: "media-missing",
minecraftServerStatus: "minecraft-server-status",
networkControllerSummary: "network-controller-summary",
networkControllerStatus: "network-controller-status",
@@ -33,11 +34,13 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
notifications: "notifications",
systemResources: "system-resources",
coolify: "coolify",
systemDisks: null,
systemDisks: "system-disks",
timetable: "timetable",
"immich-serverStats": "immich-server-stats",
"immich-albumCarousel": "immich-album-carousel",
paperlessNgx: "paperless-ngx",
patchmon: "patchmon",
bazarr: "bazarr",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
uptimeKuma: "uptime-kuma",
@@ -50,5 +53,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemGrid: "beszel-system-grid",
beszelAlerts: "beszel-alerts",
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
};
+42 -2
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 },
@@ -96,7 +98,7 @@ export const integrationDefs = {
name: "Sonarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
category: ["calendar"],
category: ["calendar", "mediaOrganizer"],
documentationUrl: createDocumentationLink("/docs/integrations/sonarr"),
defaultPort: 8989,
apiKeySettingsPath: "/settings/general",
@@ -105,7 +107,7 @@ export const integrationDefs = {
name: "Radarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
category: ["calendar"],
category: ["calendar", "mediaOrganizer"],
documentationUrl: createDocumentationLink("/docs/integrations/radarr"),
defaultPort: 7878,
apiKeySettingsPath: "/settings/general",
@@ -137,6 +139,15 @@ export const integrationDefs = {
defaultPort: 9696,
apiKeySettingsPath: "/settings/general",
},
bazarr: {
name: "Bazarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg",
category: ["subtitleManager"],
documentationUrl: createDocumentationLink("/docs/integrations/bazarr"),
defaultPort: 6767,
apiKeySettingsPath: "/settings/general",
},
jellyfin: {
name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]],
@@ -321,6 +332,14 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
defaultPort: 80,
},
synology: {
name: "Synology DiskStation",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/synology"),
defaultPort: 5000,
},
unraid: {
name: "Unraid",
secretKinds: [["apiKey"]],
@@ -353,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"]],
@@ -425,6 +453,14 @@ export const integrationDefs = {
category: ["vpn"],
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
},
traefik: {
name: "Traefik",
secretKinds: [[], ["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
category: ["reverseProxy"],
documentationUrl: createDocumentationLink("/docs/integrations/traefik"),
defaultPort: 8080,
},
archiveTeamWarrior: {
name: "ArchiveTeam Warrior",
secretKinds: [[], ["username", "password"]],
@@ -453,6 +489,7 @@ export const integrationDefs = {
"smartHomeServer",
"ups",
"uptimeMonitoring",
"mediaOrganizer",
],
documentationUrl: null,
},
@@ -518,6 +555,7 @@ export const integrationCategories = [
"dnsHole",
"mediaService",
"calendar",
"mediaOrganizer",
"mediaSearch",
"mediaRelease",
"mediaRequest",
@@ -545,6 +583,8 @@ export const integrationCategories = [
"documents",
"mediaLibrary",
"uptimeMonitoring",
"subtitleManager",
"reverseProxy",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
@@ -20,19 +20,22 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
"mediaRequests-requestList": getIntegrationKindsByCategory("mediaRequest"),
"mediaRequests-requestStats": getIntegrationKindsByCategory("mediaRequest"),
mediaTranscoding: getIntegrationKindsByCategory("mediaTranscoding"),
mediaMissing: getIntegrationKindsByCategory("mediaOrganizer"),
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"],
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"],
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
coolify: ["coolify"],
"immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"],
paperlessNgx: ["paperlessNgx"],
patchmon: ["patchmon"],
bazarr: ["bazarr"],
tracearr: ["tracearr"],
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
@@ -40,6 +43,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
traefik: ["traefik"],
};
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
+7
View File
@@ -17,6 +17,7 @@ export const widgetKinds = [
"mediaRequests-requestList",
"mediaRequests-requestStats",
"mediaTranscoding",
"mediaMissing",
"minecraftServerStatus",
"networkControllerSummary",
"networkControllerStatus",
@@ -36,6 +37,8 @@ export const widgetKinds = [
"immich-serverStats",
"immich-albumCarousel",
"paperlessNgx",
"patchmon",
"bazarr",
"tracearr",
"speedtestTracker",
"uptimeKuma",
@@ -48,6 +51,7 @@ export const widgetKinds = [
"beszelSystemGrid",
"beszelAlerts",
"beszelSystemStats",
"traefik",
"customApi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -56,4 +60,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 },
};
@@ -2,6 +2,7 @@ import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { BazarrIntegration } from "../bazarr/bazarr-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
@@ -36,6 +37,7 @@ import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-inte
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";
@@ -45,6 +47,8 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TraefikIntegration } from "../traefik/traefik-integration";
import { SynologyIntegration } from "../synology/synology-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
@@ -110,12 +114,14 @@ export const integrationCreators = {
gotify: GotifyIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
synology: SynologyIntegration,
unraid: UnraidIntegration,
coolify: CoolifyIntegration,
tracearr: TracearrIntegration,
glances: GlancesIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
patchmon: PatchMonIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
@@ -125,6 +131,8 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -0,0 +1,45 @@
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 { BazarrBadges } from "./bazarr-types";
import { bazarrBadgesSchema, bazarrSystemStatusSchema } from "./bazarr-types";
export class BazarrIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/system/status"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
bazarrSystemStatusSchema.parse(await response.json());
return { success: true };
}
public async getBadgesAsync(): Promise<BazarrBadges> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/badges"), {
headers: this.getAuthHeaders(),
timeout: 10000,
});
if (!response.ok) {
throw new Error(
`Failed to fetch Bazarr badges for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
);
}
return bazarrBadgesSchema.parse(await response.json());
}
private getAuthHeaders(): Record<string, string> {
return {
"X-API-KEY": this.getSecretValue("apiKey"),
Accept: "application/json",
};
}
}
@@ -0,0 +1,19 @@
import { z } from "zod/v4";
export const bazarrBadgesSchema = z.object({
episodes: z.number(),
movies: z.number(),
providers: z.number(),
status: z.number(),
sonarr_signalr: z.string(),
radarr_signalr: z.string(),
announcements: z.number(),
});
export type BazarrBadges = z.infer<typeof bazarrBadgesSchema>;
export const bazarrSystemStatusSchema = z.object({
data: z.object({
bazarr_version: z.string(),
}),
});
@@ -20,6 +20,7 @@ import type {
BeszelSystemdService,
BeszelSystemStatsRecord,
CreateAlertInput,
LiveStatsEvent,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
@@ -284,6 +285,125 @@ 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.
*/
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 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;
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",
headers: {
Authorization: session.token,
"Content-Type": "application/json",
},
body: JSON.stringify({
clientId: parsed.clientId,
subscriptions: ["system_stats", "container_stats"],
}),
signal,
});
if (!subscribeResponse.ok) {
logger.warn("Beszel SSE subscription POST failed", {
integrationId: this.integration.id,
systemId,
status: subscribeResponse.status,
});
throw new ResponseError(subscribeResponse);
}
logger.debug("Beszel SSE subscribed to collections", {
integrationId: this.integration.id,
systemId,
clientId: parsed.clientId,
});
return;
}
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
}
};
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 });
}
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
@@ -343,6 +343,22 @@ export interface UpdateAlertInput {
min?: number;
}
/**
* Live SSE subscription event types for real-time Beszel metrics.
* Discriminated union — clients accumulate each type into its own buffer.
*/
export interface LiveSystemStatsEvent {
type: "system_stats";
record: BeszelSystemStatsRecord;
}
export interface LiveContainerStatsEvent {
type: "container_stats";
record: BeszelContainerStatsRecord;
}
export type LiveStatsEvent = LiveSystemStatsEvent | LiveContainerStatsEvent;
export interface PocketBaseListResponse<T> {
page: number;
perPage: number;
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = await this.getClientAsync();
const torrents = await client.listTorrents({ limit: input.limit });
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 },
@@ -99,11 +99,14 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
password: this.getSecretValue("password"),
};
return new QBittorrent({
const client = new QBittorrent({
...credentials,
baseUrl: this.url("/").toString(),
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
});
// Populate version state so the library uses correct v5 endpoints (stop/start vs pause/resume)
await client.getAppVersion();
return client;
}
private static getTorrentState(state: string): DownloadClientItem["state"] {
@@ -125,6 +128,8 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
return "seeding";
case "pausedDL":
case "pausedUP":
case "stoppedDL":
case "stoppedUP":
return "paused";
case "stalledDL":
return "stalled";
@@ -1,12 +1,13 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ImageProxy } from "@homarr/image-proxy";
import { Integration } from "../base/integration";
import type { IntegrationTestingInput } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { Notification } from "../interfaces/notifications/notification-types";
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
import { gotifyMessagesResponseSchema } from "./gotify-schema";
import { gotifyApplicationsResponseSchema, gotifyMessagesResponseSchema } from "./gotify-schema";
export class GotifyIntegration extends Integration implements INotificationsIntegration {
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
@@ -21,23 +22,90 @@ export class GotifyIntegration extends Integration implements INotificationsInte
return { Authorization: `Basic ${credentials}` };
}
private getMessagesUrl(applicationId: number) {
const url = this.externalUrl("/");
url.hash = `/messages/${applicationId}`;
return url.toString();
}
private async getApplicationsByIdAsync(): Promise<Map<number, { name: string; image?: string | null }>> {
try {
const response = await fetchWithTrustedCertificatesAsync(this.url("/application"), {
headers: this.getHeaders(),
});
if (!response.ok) throw new ResponseError(response);
const result = await gotifyApplicationsResponseSchema.safeParseAsync(await response.json());
if (!result.success) return new Map();
return new Map(result.data.map((application) => [application.id, application]));
} catch {
return new Map();
}
}
private async getApplicationIconUrlAsync(
imageProxy: ImageProxy,
imagePath: string | null | undefined,
): Promise<string | undefined> {
const trimmedImagePath = imagePath?.trim();
if (!trimmedImagePath) return undefined;
try {
const imageUrl = this.getApplicationImageUrl(trimmedImagePath);
return await imageProxy.createImageAsync(imageUrl, this.getHeaders());
} catch {
return undefined;
}
}
private getApplicationImageUrl(imagePath: string) {
try {
return new URL(imagePath).toString();
} catch {
const path = imagePath.startsWith("/") ? imagePath : `/${imagePath}`;
return this.url(path as `/${string}`).toString();
}
}
public async getNotificationsAsync(): Promise<Notification[]> {
const url = this.url("/message", { limit: 100 });
const response = await fetchWithTrustedCertificatesAsync(url, { headers: this.getHeaders() });
const [messagesResponse, applicationsById] = await Promise.all([
fetchWithTrustedCertificatesAsync(this.url("/message", { limit: 100 }), { headers: this.getHeaders() }),
this.getApplicationsByIdAsync(),
]);
if (!response.ok) throw new ResponseError(response);
if (!messagesResponse.ok) throw new ResponseError(messagesResponse);
const result = await gotifyMessagesResponseSchema.safeParseAsync(await response.json());
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
const parsed = result.data;
const messagesResult = await gotifyMessagesResponseSchema.safeParseAsync(await messagesResponse.json());
if (!messagesResult.success) throw new Error(`Failed to parse Gotify response: ${messagesResult.error.message}`);
return parsed.messages.map(
(message): Notification => ({
const imageProxy = new ImageProxy();
const iconUrlsByApplicationId = new Map<number, string | undefined>();
await Promise.all(
[...applicationsById.entries()].map(async ([applicationId, application]) => {
iconUrlsByApplicationId.set(
applicationId,
await this.getApplicationIconUrlAsync(imageProxy, application.image),
);
}),
);
return messagesResult.data.messages.map((message): Notification => {
const application = applicationsById.get(message.appid);
return {
id: String(message.id),
time: new Date(message.date),
title: message.title,
body: message.message,
}),
);
href: this.getMessagesUrl(message.appid),
source: application
? {
name: application.name,
iconUrl: iconUrlsByApplicationId.get(message.appid),
}
: undefined,
};
});
}
}
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
// See: https://gotify.net/api-docs#/message/getMessages
export const gotifyMessageSchema = z.object({
id: z.number(),
appid: z.number(),
date: z.string(),
title: z.string(),
message: z.string(),
@@ -12,3 +13,11 @@ export const gotifyMessageSchema = z.object({
export const gotifyMessagesResponseSchema = z.object({
messages: z.array(gotifyMessageSchema),
});
export const gotifyApplicationSchema = z.object({
id: z.number(),
name: z.string(),
image: z.string().nullable().optional(),
});
export const gotifyApplicationsResponseSchema = z.array(gotifyApplicationSchema);
+6
View File
@@ -29,6 +29,7 @@ export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { TrueNasIntegration } from "./truenas/truenas-integration";
export { SynologyIntegration } from "./synology/synology-integration";
export { UnraidIntegration } from "./unraid/unraid-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
export { ICalIntegration } from "./ical/ical-integration";
@@ -41,6 +42,8 @@ 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 { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -67,6 +70,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,
@@ -80,6 +84,8 @@ export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshel
export type { NavidromeDashboardData } from "./navidrome/navidrome-types";
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
export type { BazarrBadges } from "./bazarr/bazarr-types";
export type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "./traefik/traefik-types";
export type {
ArchiveTeamWarriorBandwidth,
ArchiveTeamWarriorItem,
@@ -0,0 +1,6 @@
import type { MissingMediaItem, QueuedMediaItem } from "./media-organizer-types";
export interface IMediaOrganizerIntegration {
getMissingAsync(pageSize?: number): Promise<{ items: MissingMediaItem[]; totalCount: number }>;
getMediaQueueAsync(pageSize?: number): Promise<{ items: QueuedMediaItem[]; totalCount: number }>;
}
@@ -0,0 +1,26 @@
export interface MissingMediaItem {
id: number;
title: string;
type: "movie" | "episode";
year?: number;
seasonNumber?: number;
episodeNumber?: number;
seriesTitle?: string;
imageUrl?: string | null;
link: string;
}
export interface QueuedMediaItem {
id: number;
title: string;
type: "movie" | "episode";
status: string;
timeLeft: string | null;
percentComplete: number;
year?: number;
seasonNumber?: number;
episodeNumber?: number;
seriesTitle?: string;
imageUrl?: string | null;
link: string;
}
@@ -3,4 +3,9 @@ export interface Notification {
time: Date;
title: string;
body: string;
href?: string;
source?: {
name: string;
iconUrl?: string;
};
}
@@ -10,11 +10,13 @@ import type { TestingResult } from "../../base/test-connection/test-connection-s
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types";
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "radarrIntegration" });
export class RadarrIntegration extends Integration implements ICalendarIntegration {
export class RadarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
/**
* Gets the events in the Radarr calendar between two dates.
* @param start The start date
@@ -111,6 +113,79 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
return bestImage.remoteUrl;
};
async getMissingAsync(pageSize = 10): Promise<{ items: MissingMediaItem[]; totalCount: number }> {
const url = this.url("/api/v3/wanted/missing", {
page: 1,
pageSize,
sortKey: "title",
sortDirection: "ascending",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
totalRecords: z.number(),
records: z.array(radarrMissingMovieSchema),
})
.parseAsync(await response.json());
return {
totalCount: data.totalRecords,
items: data.records.map(
(movie): MissingMediaItem => ({
id: movie.id,
title: movie.title,
type: "movie",
year: movie.year,
imageUrl: movie.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
link: this.externalUrl(`/movie/${movie.titleSlug}`).toString(),
}),
),
};
}
async getMediaQueueAsync(pageSize = 10): Promise<{ items: QueuedMediaItem[]; totalCount: number }> {
const url = this.url("/api/v3/queue", {
page: 1,
pageSize,
includeMovie: true,
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
totalRecords: z.number(),
records: z.array(radarrQueueItemSchema),
})
.parseAsync(await response.json());
return {
totalCount: data.totalRecords,
items: data.records.map((item): QueuedMediaItem => {
const sizeLeft = item.sizeleft ?? 0;
const sizeTotal = item.size ?? 0;
const percentComplete = sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : 0;
return {
id: item.id,
title: item.movie?.title ?? item.title,
type: "movie",
status: item.status,
timeLeft: item.timeleft ?? null,
percentComplete,
year: item.movie?.year,
imageUrl: item.movie?.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
link: item.movie?.titleSlug
? this.externalUrl(`/movie/${item.movie.titleSlug}`).toString()
: this.externalUrl("/activity/queue").toString(),
};
}),
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
@@ -131,6 +206,41 @@ const radarrCalendarEventImageSchema = z.array(
}),
);
const radarrMissingMovieSchema = z.object({
id: z.number(),
title: z.string(),
year: z.number().optional(),
titleSlug: z.string(),
images: z.array(
z.object({
coverType: z.string(),
remoteUrl: z.string().url(),
}),
),
});
const radarrQueueItemSchema = z.object({
id: z.number(),
title: z.string(),
status: z.string(),
timeleft: z.string().optional(),
size: z.number().optional(),
sizeleft: z.number().optional(),
movie: z
.object({
title: z.string(),
year: z.number().optional(),
titleSlug: z.string(),
images: z.array(
z.object({
coverType: z.string(),
remoteUrl: z.string().url(),
}),
),
})
.optional(),
});
const radarrCalendarEventSchema = z.object({
title: z.string(),
originalTitle: z.string(),
@@ -9,11 +9,13 @@ import { TestConnectionError } from "../../base/test-connection/test-connection-
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "sonarrIntegration" });
export class SonarrIntegration extends Integration implements ICalendarIntegration {
export class SonarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
/**
* Gets the events in the Sonarr calendar between two dates.
* @param start The start date
@@ -107,6 +109,88 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
return bestImage.remoteUrl;
};
async getMissingAsync(pageSize = 10): Promise<{ items: MissingMediaItem[]; totalCount: number }> {
const url = this.url("/api/v3/wanted/missing", {
page: 1,
pageSize,
sortKey: "airDateUtc",
sortDirection: "ascending",
includeSeries: true,
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
totalRecords: z.number(),
records: z.array(sonarrMissingEpisodeSchema),
})
.parseAsync(await response.json());
return {
totalCount: data.totalRecords,
items: data.records.map(
(episode): MissingMediaItem => ({
id: episode.id,
title: episode.title,
type: "episode",
seasonNumber: episode.seasonNumber,
episodeNumber: episode.episodeNumber,
seriesTitle: episode.series?.title,
year: episode.series?.year,
imageUrl: episode.series?.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
link: episode.series?.titleSlug
? this.externalUrl(`/series/${episode.series.titleSlug}`).toString()
: this.externalUrl("/wanted/missing").toString(),
}),
),
};
}
async getMediaQueueAsync(pageSize = 10): Promise<{ items: QueuedMediaItem[]; totalCount: number }> {
const url = this.url("/api/v3/queue", {
page: 1,
pageSize,
includeSeries: true,
includeEpisode: true,
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
totalRecords: z.number(),
records: z.array(sonarrQueueItemSchema),
})
.parseAsync(await response.json());
return {
totalCount: data.totalRecords,
items: data.records.map((item): QueuedMediaItem => {
const sizeLeft = item.sizeleft ?? 0;
const sizeTotal = item.size ?? 0;
const percentComplete = sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : 0;
return {
id: item.id,
title: item.episode?.title ?? item.title,
type: "episode",
status: item.status,
timeLeft: item.timeleft ?? null,
percentComplete,
seasonNumber: item.episode?.seasonNumber,
episodeNumber: item.episode?.episodeNumber,
seriesTitle: item.series?.title,
imageUrl: item.series?.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
link: item.series?.titleSlug
? this.externalUrl(`/series/${item.series.titleSlug}`).toString()
: this.externalUrl("/activity/queue").toString(),
};
}),
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
@@ -119,6 +203,54 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
}
}
const sonarrMissingEpisodeSchema = z.object({
id: z.number(),
title: z.string(),
seasonNumber: z.number(),
episodeNumber: z.number(),
series: z
.object({
title: z.string(),
year: z.number().optional(),
titleSlug: z.string(),
images: z.array(
z.object({
coverType: z.string(),
remoteUrl: z.string().url(),
}),
),
})
.optional(),
});
const sonarrQueueItemSchema = z.object({
id: z.number(),
title: z.string(),
status: z.string(),
timeleft: z.string().optional(),
size: z.number().optional(),
sizeleft: z.number().optional(),
series: z
.object({
title: z.string(),
titleSlug: z.string(),
images: z.array(
z.object({
coverType: z.string(),
remoteUrl: z.string().url(),
}),
),
})
.optional(),
episode: z
.object({
title: z.string(),
seasonNumber: z.number(),
episodeNumber: z.number(),
})
.optional(),
});
const sonarrCalendarEventImageSchema = z.array(
z.object({
// See https://github.com/Sonarr/Sonarr/blob/9e5ebdc6245d4714776b53127a1e6b63c25fbcb9/src/NzbDrone.Core/MediaCover/MediaCover.cs#L5-L14
@@ -7,6 +7,7 @@ import type {
BeszelSystemDetails,
BeszelSystemStats,
BeszelSystemStatsRecord,
LiveStatsEvent,
} from "../../beszel/beszel-types";
const GB = 1024 * 1024 * 1024;
@@ -332,7 +333,7 @@ export class BeszelMockService {
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise<void> {
const sys = resolveSystem(systemId);
@@ -348,7 +349,7 @@ export class BeszelMockService {
created: new Date().toISOString(),
updated: new Date().toISOString(),
};
onMessage({ stats: record, containerStats: null });
onMessage({ type: "system_stats", record });
};
if (signal.aborted) return;
await new Promise<void>((resolve) => {
@@ -0,0 +1,116 @@
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
const missingItems: MissingMediaItem[] = [
{
id: 1,
title: "Dune: Part Two",
type: "movie",
year: 2024,
imageUrl: "https://image.tmdb.org/t/p/w300/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg",
link: "https://www.imdb.com/title/tt15239678/",
},
{
id: 2,
title: "The Batman Part II",
type: "movie",
year: 2026,
imageUrl: "https://image.tmdb.org/t/p/w300/74xTEgt7R36Fpooo50r9T25onhq.jpg",
link: "https://www.imdb.com/title/tt7126948/",
},
{
id: 3,
title: "The Last of Us",
type: "episode",
seasonNumber: 2,
episodeNumber: 5,
seriesTitle: "The Last of Us",
year: 2025,
imageUrl: "https://image.tmdb.org/t/p/w300/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg",
link: "https://www.imdb.com/title/tt3581920/",
},
{
id: 4,
title: "Andor",
type: "episode",
seasonNumber: 2,
episodeNumber: 3,
seriesTitle: "Andor",
year: 2025,
imageUrl: "https://image.tmdb.org/t/p/w300/59SVNwLfoMnZPPB6ukW6dlPxAdI.jpg",
link: "https://www.imdb.com/title/tt9253284/",
},
{
id: 5,
title: "Gladiator II",
type: "movie",
year: 2024,
imageUrl: "https://image.tmdb.org/t/p/w300/2cxhvwyEwRlysAmRH4iodkvo0z5.jpg",
link: "https://www.imdb.com/title/tt9218128/",
},
];
const queuedItems: QueuedMediaItem[] = [
{
id: 101,
title: "Oppenheimer",
type: "movie",
status: "downloading",
timeLeft: "1h 23m",
percentComplete: 62,
imageUrl: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
link: "https://www.imdb.com/title/tt15398776/",
},
{
id: 102,
title: "Shogun",
type: "episode",
seasonNumber: 1,
episodeNumber: 8,
seriesTitle: "Shogun",
status: "downloading",
timeLeft: "47m",
percentComplete: 81,
imageUrl: "https://image.tmdb.org/t/p/w300/7O4iVfOMQmdCSxhOg1WnzG1AgYT.jpg",
link: "https://www.imdb.com/title/tt2098771/",
},
{
id: 103,
title: "The Substance",
type: "movie",
status: "queued",
timeLeft: null,
percentComplete: 0,
imageUrl: "https://image.tmdb.org/t/p/w300/lqoMzCcZYEFK729d6qzt349fB4o.jpg",
link: "https://www.imdb.com/title/tt17526714/",
},
{
id: 104,
title: "House of the Dragon",
type: "episode",
seasonNumber: 2,
episodeNumber: 7,
seriesTitle: "House of the Dragon",
status: "importing",
timeLeft: "3m",
percentComplete: 99,
imageUrl: "https://image.tmdb.org/t/p/w300/z2yahl2uefxDCl0nogcRBstwruJ.jpg",
link: "https://www.imdb.com/title/tt11198330/",
},
];
export class MediaOrganizerMockService implements IMediaOrganizerIntegration {
async getMissingAsync(pageSize = 10) {
return {
items: missingItems.slice(0, pageSize),
totalCount: missingItems.length,
};
}
async getMediaQueueAsync(pageSize = 10) {
return {
items: queuedItems.slice(0, pageSize),
totalCount: queuedItems.length,
};
}
}
@@ -9,6 +9,7 @@ import type {
ISystemHealthMonitoringIntegration,
} from "../interfaces/health-monitoring/health-monitoring-integration";
import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration";
import type { IMediaOrganizerIntegration } from "../interfaces/media-organizer/media-organizer-integration";
import type { IMediaReleasesIntegration } from "../interfaces/media-releases";
import type { IMediaRequestIntegration } from "../interfaces/media-requests/media-request-integration";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
@@ -21,6 +22,7 @@ import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monito
import { DnsHoleMockService } from "./data/dns-hole";
import { DownloadClientMockService } from "./data/download";
import { IndexerManagerMockService } from "./data/indexer-manager";
import { MediaOrganizerMockService } from "./data/media-organizer";
import { MediaReleasesMockService } from "./data/media-releases";
import { MediaRequestMockService } from "./data/media-request";
import { MediaServerMockService } from "./data/media-server";
@@ -41,6 +43,7 @@ export class MockIntegration
IClusterHealthMonitoringIntegration,
ISystemHealthMonitoringIntegration,
IIndexerManagerIntegration,
IMediaOrganizerIntegration,
IMediaReleasesIntegration,
IMediaRequestIntegration,
IMediaServerIntegration,
@@ -55,6 +58,7 @@ export class MockIntegration
private static readonly clusterMonitoring = new ClusterHealthMonitoringMockService();
private static readonly systemMonitoring = new SystemHealthMonitoringMockService();
private static readonly indexerManager = new IndexerManagerMockService();
private static readonly mediaOrganizer = new MediaOrganizerMockService();
private static readonly mediaReleases = new MediaReleasesMockService();
private static readonly mediaRequest = new MediaRequestMockService();
private static readonly mediaServer = new MediaServerMockService();
@@ -97,6 +101,10 @@ export class MockIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
testAllAsync = MockIntegration.indexerManager.testAllAsync.bind(MockIntegration.indexerManager);
// MediaOrganizerIntegration
getMissingAsync = MockIntegration.mediaOrganizer.getMissingAsync.bind(MockIntegration.mediaOrganizer);
getMediaQueueAsync = MockIntegration.mediaOrganizer.getMediaQueueAsync.bind(MockIntegration.mediaOrganizer);
// MediaReleasesIntegration
getMediaReleasesAsync = MockIntegration.mediaReleases.getMediaReleasesAsync.bind(MockIntegration.mediaReleases);
@@ -132,11 +132,14 @@ export class OverseerrIntegration
},
);
const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
const allRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, sort: "modified" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
});
);
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results;
@@ -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);
});
});
@@ -0,0 +1,404 @@
import type { Headers } from "undici";
import type { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
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,
REQUEST_TIMEOUT_MS,
SESSION_ERROR_CODES,
synologyApiInfoResponseSchema,
synologyAuthResponseSchema,
synologyEnvelopeSchema,
synologyLegacyStorageDataSchema,
synologySmartInfoDataSchema,
synologyStorageLoadInfoDataSchema,
synologyStorageV2DataSchema,
synologySystemInfoDataSchema,
synologySystemStatusDataSchema,
synologyUpgradeCheckDataSchema,
synologyUpgradeStatusDataSchema,
synologyUtilizationDataSchema,
} from "./synology-types";
const logger = createLogger({ module: "synologyClient" });
const AUTH_SESSION_NAME = "DownloadStation";
type ApiDefinition = {
cgiPath: string;
maxVersion: number;
};
type StoredSession = {
cookieHeader: string;
};
type SynologyClientOptions = {
integrationId: string;
baseUrl: string;
username: string;
password: string;
sessionStore: SessionStore<StoredSession>;
};
const apiDefinitionCache = new Map<string, ApiDefinition>();
export class SynologyClient {
private readonly integrationId: string;
private readonly baseUrl: string;
private readonly username: string;
private readonly password: string;
private readonly sessionStore: SessionStore<StoredSession>;
constructor(options: SynologyClientOptions) {
this.integrationId = options.integrationId;
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.username = options.username;
this.password = options.password;
this.sessionStore = options.sessionStore;
}
public async getSystemInfoAsync() {
const response = await this.requestAsync("SYNO.Core.System", { method: "info" });
return synologySystemInfoDataSchema.parse(response.data);
}
public async getUtilizationAsync() {
const response = await this.requestAsync("SYNO.Core.System.Utilization", { method: "get" });
return synologyUtilizationDataSchema.parse(response.data);
}
public async getStorageVolumesAsync(): Promise<SynologyVolumeRecord[]> {
try {
const storageV2Response = await this.requestAsync("SYNO.Core.System", {
method: "info",
extraParams: { type: "storage_v2" },
});
const storageV2 = synologyStorageV2DataSchema.parse(storageV2Response.data);
const volumesFromV2 = this.mapStorageV2Volumes(storageV2);
if (volumesFromV2.length > 0) {
return volumesFromV2;
}
} catch (error) {
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
integrationId: this.integrationId,
error,
});
}
const legacyResponse = await this.requestAsync("SYNO.Core.System", {
method: "info",
extraParams: { type: "storage" },
});
const legacyStorage = synologyLegacyStorageDataSchema.parse(legacyResponse.data);
return this.mapLegacyVolumes(legacyStorage.vol_info ?? []);
}
public async getStorageLoadInfoAsync() {
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Storage", { method: "load_info" });
if (!response) {
return null;
}
return synologyStorageLoadInfoDataSchema.parse(response.data);
}
public async getSmartInfoAsync() {
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Smart", { method: "get_smart_info" });
if (!response) {
return null;
}
return synologySmartInfoDataSchema.parse(response.data);
}
public async getSystemStatusAsync() {
const response = await this.requestOptionalAsync("SYNO.Core.System.Status", { method: "get" });
if (!response) {
return null;
}
return synologySystemStatusDataSchema.parse(response.data);
}
public async getUpgradeStatusAsync() {
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade", { method: "status" });
if (!response) {
return null;
}
return synologyUpgradeStatusDataSchema.parse(response.data);
}
public async getUpgradeCheckAsync() {
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade.Server", { method: "check" });
if (!response) {
return null;
}
return synologyUpgradeCheckDataSchema.parse(response.data);
}
public async testConnectionAsync() {
await this.getSystemInfoAsync();
}
private mapLegacyVolumes(volumes: z.infer<typeof synologyLegacyStorageDataSchema>["vol_info"]) {
return (volumes ?? [])
.map((volume) => this.mapVolumeRecord(volume.name, volume.used_size, volume.total_size, volume.status))
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
}
private mapStorageV2Volumes(storage: z.infer<typeof synologyStorageV2DataSchema>) {
return (storage.volumes ?? [])
.map((volume) => {
const name = volume.id ?? volume.vol_path ?? volume.display_name;
if (!name) {
return null;
}
return this.mapVolumeRecord(name, volume.size?.used, volume.size?.total, volume.status, volume.display_name);
})
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
}
private mapVolumeRecord(
name: string,
usedValue: number | string | undefined,
totalValue: number | string | undefined,
status: string | undefined,
displayName?: string,
): SynologyVolumeRecord | null {
const usedBytes = parseNumericValue(usedValue);
const totalBytes = parseNumericValue(totalValue);
if (usedBytes === null || totalBytes === null || totalBytes <= 0) {
return null;
}
return {
name,
displayName: displayName && displayName !== name ? displayName : undefined,
usedBytes,
totalBytes,
status,
};
}
public mapDisksFromLoadInfo(loadInfo: z.infer<typeof synologyStorageLoadInfoDataSchema>): SynologyDiskRecord[] {
return (loadInfo.disks ?? []).map((disk) => ({
identifier: disk.id ?? disk.name ?? disk.display_name ?? "unknown-disk",
name: disk.display_name ?? disk.name ?? disk.id ?? "unknown-disk",
status: disk.smart_status ?? disk.status,
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
volumeName: disk.volume_id ?? disk.vol_path,
}));
}
public mapDisksFromSmartInfo(smartInfo: z.infer<typeof synologySmartInfoDataSchema>): SynologyDiskRecord[] {
const disks = smartInfo.disks ?? smartInfo.items ?? [];
return disks.map((disk) => ({
identifier: disk.disk_id ?? disk.id ?? disk.name ?? "unknown-disk",
name: disk.name ?? disk.disk_id ?? disk.id ?? "unknown-disk",
status: disk.overall_status ?? disk.status,
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
volumeName: undefined,
}));
}
private async requestOptionalAsync(
apiName: string,
options: { method: string; extraParams?: Record<string, string> },
) {
try {
return await this.requestAsync(apiName, options);
} catch (error) {
logger.debug("Optional Synology API request failed", {
integrationId: this.integrationId,
apiName,
method: options.method,
error,
});
return null;
}
}
private async requestAsync(
apiName: string,
options: { method: string; extraParams?: Record<string, string> },
): Promise<z.infer<typeof synologyEnvelopeSchema>> {
return await this.withAuthenticatedSessionAsync(async (session) => {
const apiDefinition = await this.getApiDefinitionAsync(apiName);
const requestUrl = new URL(`${this.baseUrl}/webapi/${apiDefinition.cgiPath}`);
requestUrl.searchParams.set("api", apiName);
requestUrl.searchParams.set("version", String(apiDefinition.maxVersion));
requestUrl.searchParams.set("method", options.method);
for (const [key, value] of Object.entries(options.extraParams ?? {})) {
requestUrl.searchParams.set(key, value);
}
const response = await fetchWithTrustedCertificatesAsync(requestUrl, {
headers: {
Cookie: session.cookieHeader,
"User-Agent": "Homarr",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new ResponseError(response);
}
const payload = synologyEnvelopeSchema.parse(await response.json());
if (!payload.success) {
const errorCode = payload.error?.code;
if (errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)) {
throw new SynologySessionError(errorCode);
}
throw new ResponseError({
status: 502,
url: requestUrl.toString(),
});
}
return payload;
});
}
private async withAuthenticatedSessionAsync<T>(callback: (session: StoredSession) => Promise<T>): Promise<T> {
const storedSession = await this.sessionStore.getAsync();
if (storedSession) {
try {
return await callback(storedSession);
} catch (error) {
if (!(error instanceof SynologySessionError)) {
throw error;
}
logger.debug("Synology session expired, re-authenticating", {
integrationId: this.integrationId,
errorCode: error.errorCode,
});
await this.sessionStore.clearAsync();
}
}
const session = await this.loginAsync();
await this.sessionStore.setAsync(session);
return await callback(session);
}
private async loginAsync(): Promise<StoredSession> {
const authDefinition = await this.getApiDefinitionAsync(AUTH_API_NAME);
const loginUrl = new URL(`${this.baseUrl}/webapi/${authDefinition.cgiPath}`);
loginUrl.searchParams.set("api", AUTH_API_NAME);
loginUrl.searchParams.set("version", String(authDefinition.maxVersion));
loginUrl.searchParams.set("method", "login");
const safeLoginUrl = loginUrl.toString();
const response = await fetchWithTrustedCertificatesAsync(loginUrl, {
method: "POST",
headers: {
"User-Agent": "Homarr",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
account: this.username,
passwd: this.password,
session: AUTH_SESSION_NAME,
format: "cookie",
}).toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new ResponseError({ status: response.status, url: safeLoginUrl });
}
const payload = synologyAuthResponseSchema.parse(await response.json());
if (!payload.success) {
throw new ResponseError({
status: 401,
url: safeLoginUrl,
});
}
const cookieHeader = extractCookieHeader(response.headers);
if (!cookieHeader) {
throw new ResponseError({
status: 401,
url: safeLoginUrl,
});
}
return { cookieHeader };
}
private async getApiDefinitionAsync(apiName: string): Promise<ApiDefinition> {
const cacheKey = `${this.integrationId}:${apiName}`;
const cachedDefinition = apiDefinitionCache.get(cacheKey);
if (cachedDefinition) {
return cachedDefinition;
}
const infoUrl = new URL(`${this.baseUrl}/webapi/query.cgi`);
infoUrl.searchParams.set("api", INFO_API_NAME);
infoUrl.searchParams.set("version", "1");
infoUrl.searchParams.set("method", "query");
infoUrl.searchParams.set("query", apiName);
const response = await fetchWithTrustedCertificatesAsync(infoUrl, {
headers: {
"User-Agent": "Homarr",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new ResponseError(response);
}
const payload = synologyApiInfoResponseSchema.parse(await response.json());
const apiDefinition = payload.data?.[apiName];
if (!payload.success || !apiDefinition) {
throw new ResponseError({
status: 502,
url: infoUrl.toString(),
});
}
const definition = {
cgiPath: apiDefinition.path,
maxVersion: apiDefinition.maxVersion,
};
apiDefinitionCache.set(cacheKey, definition);
return definition;
}
}
class SynologySessionError extends Error {
constructor(public readonly errorCode: number) {
super(`Synology session error ${String(errorCode)}`);
this.name = SynologySessionError.name;
}
}
function extractCookieHeader(headers: Headers): string | null {
const cookies = headers.getSetCookie();
if (cookies.length === 0) {
return null;
}
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
}
function parseNumericValue(value: number | string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseOptionalNumericValue(value: number | string | undefined): number | null {
const parsed = parseNumericValue(value);
return parsed === null ? null : parsed;
}
@@ -0,0 +1,323 @@
import type { z } from "zod";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { createSessionStore } from "../base/session-store";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
import { SynologyClient } from "./synology-client";
import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types";
import type { synologySystemInfoDataSchema, synologyUtilizationDataSchema } from "./synology-types";
const logger = createLogger({ module: "synologyIntegration" });
type StoredSession = {
cookieHeader: string;
};
@HandleIntegrationErrors([])
export class SynologyIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private readonly sessionStore = createSessionStore<StoredSession>(this.integration);
private client?: SynologyClient;
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
await this.getClient().testConnectionAsync();
return { success: true };
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const client = this.getClient();
const [systemInfo, utilization, volumes] = await Promise.all([
client.getSystemInfoAsync(),
client.getUtilizationAsync(),
client.getStorageVolumesAsync(),
]);
const [storageLoadInfoResult, smartInfoResult, systemStatusResult, upgradeStatusResult, upgradeCheckResult] =
await Promise.allSettled([
client.getStorageLoadInfoAsync(),
client.getSmartInfoAsync(),
client.getSystemStatusAsync(),
client.getUpgradeStatusAsync(),
client.getUpgradeCheckAsync(),
]);
const storageLoadInfo = getSettledValue(storageLoadInfoResult);
const smartInfo = getSettledValue(smartInfoResult);
const systemStatus = getSettledValue(systemStatusResult);
const upgradeStatus = getSettledValue(upgradeStatusResult);
const upgradeCheck = getSettledValue(upgradeCheckResult);
const diskRecords = mergeDiskRecords(
storageLoadInfo ? client.mapDisksFromLoadInfo(storageLoadInfo) : [],
smartInfo ? client.mapDisksFromSmartInfo(smartInfo) : [],
);
const memory = mapMemoryBytes(utilization);
const network = mapNetworkTraffic(utilization);
const loadAverage = mapLoadAverage(utilization);
const smart = mapVolumeSmartEntries(volumes, diskRecords);
const rebootRequired = Boolean(systemStatus?.reboot_required ?? upgradeStatus?.reboot_required ?? false);
const availablePkgUpdates = upgradeCheck?.update_count ?? (upgradeCheck?.available ? 1 : 0);
return {
version: mapVersion(systemInfo),
cpuModelName: systemInfo.model ?? "Unknown CPU",
cpuUtilization: mapCpuUtilization(utilization),
memUsedInBytes: memory.usedBytes,
memAvailableInBytes: memory.availableBytes,
uptime: mapUptimeSeconds(systemInfo),
network,
loadAverage,
rebootRequired,
availablePkgUpdates,
cpuTemp: mapCpuTemperature(systemInfo),
fileSystem: mapFileSystemEntries(volumes),
smart,
gpu: [],
};
}
public async listStorageVolumesAsync(): Promise<{ value: string; label: string }[]> {
const volumes = await this.getClient().getStorageVolumesAsync();
const { id: integrationId, name: integrationName } = this.integration;
return volumes.map((volume) => ({
value: `${integrationId}:${volume.name}`,
label: `${volume.displayName ?? volume.name} (${integrationName})`,
}));
}
private getClient(): SynologyClient {
return (this.client ??= new SynologyClient({
integrationId: this.integration.id,
baseUrl: this.integration.url,
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
sessionStore: this.sessionStore,
}));
}
}
function getSettledValue<T>(result: PromiseSettledResult<T>): T | null {
if (result.status === "rejected") {
logger.debug("Optional Synology API call failed", { reason: result.reason });
return null;
}
return result.value;
}
function mapVersion(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): string {
if (systemInfo.version_string) {
return systemInfo.version_string;
}
if (systemInfo.firmware_ver) {
return systemInfo.firmware_ver;
}
if (systemInfo.version !== undefined) {
return String(systemInfo.version);
}
return "Unknown";
}
function mapCpuUtilization(utilization: z.infer<typeof synologyUtilizationDataSchema>): number {
const userLoad = parseNumericValue(utilization.cpu?.user_load) ?? 0;
const systemLoad = parseNumericValue(utilization.cpu?.system_load) ?? 0;
const totalLoad = userLoad + systemLoad;
return Math.min(totalLoad, 100);
}
function mapMemoryBytes(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
const memory = utilization.memory;
const totalRealKib = parseNumericValue(memory?.total_real);
const realUsagePercent = parseNumericValue(memory?.real_usage);
// DSM utilization API reports memory sizes in KiB, not bytes.
if (totalRealKib !== null && realUsagePercent !== null) {
const totalBytes = totalRealKib * 1024;
const usedBytes = Math.round((totalBytes * realUsagePercent) / 100);
return {
usedBytes,
availableBytes: Math.max(totalBytes - usedBytes, 0),
};
}
const availableKib = parseNumericValue(memory?.avail_real) ?? 0;
const cachedKib = parseNumericValue(memory?.cached) ?? 0;
const totalKib = totalRealKib ?? availableKib + cachedKib;
const usedKib = Math.max(totalKib - availableKib, 0);
return {
usedBytes: usedKib * 1024,
availableBytes: availableKib * 1024,
};
}
function mapNetworkTraffic(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
const totalNetwork = utilization.network?.find((entry) => entry.device === "total");
if (!totalNetwork) {
return null;
}
const received = parseNumericValue(totalNetwork.rx);
const transmitted = parseNumericValue(totalNetwork.tx);
if (received === null || transmitted === null) {
return null;
}
return {
up: transmitted,
down: received,
};
}
function mapLoadAverage(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
const oneMinute = parseNumericValue(utilization.cpu?.["1min_load"]);
const fiveMinute = parseNumericValue(utilization.cpu?.["5min_load"]);
const fifteenMinute = parseNumericValue(utilization.cpu?.["15min_load"]);
if (oneMinute === null && fiveMinute === null && fifteenMinute === null) {
return null;
}
return {
"1min": oneMinute ?? 0,
"5min": fiveMinute ?? 0,
"15min": fifteenMinute ?? 0,
};
}
function mapUptimeSeconds(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): number {
if (typeof systemInfo.uptime === "number") {
return systemInfo.uptime;
}
if (!systemInfo.up_time) {
return 0;
}
const parts = systemInfo.up_time.split(":").map((part) => Number.parseInt(part, 10));
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
return 0;
}
const [hours = 0, minutes = 0, seconds = 0] = parts;
return hours * 3600 + minutes * 60 + seconds;
}
function mapCpuTemperature(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): number | undefined {
const temperature = parseNumericValue(systemInfo.sys_temp ?? systemInfo.temperature);
return temperature === null ? undefined : temperature;
}
function mapFileSystemEntries(volumes: SynologyVolumeRecord[]) {
return volumes.map((volume) => {
const availableBytes = Math.max(volume.totalBytes - volume.usedBytes, 0);
return {
deviceName: volume.name,
used: `${volume.usedBytes}`,
available: `${availableBytes}`,
percentage: (volume.usedBytes / volume.totalBytes) * 100,
};
});
}
function mapVolumeSmartEntries(volumes: SynologyVolumeRecord[], diskRecords: SynologyDiskRecord[]) {
return volumes.map((volume) => {
const matchingDisks = diskRecords.filter((disk) => diskBelongsToVolume(disk, volume.name));
const temperatures = matchingDisks
.map((disk) => disk.temperature)
.filter((temperature): temperature is number => temperature !== null);
const overallStatus = volume.status ?? pickWorstDiskStatus(matchingDisks);
return {
deviceName: volume.name,
healthy: isHealthyStatus(overallStatus),
overallStatus,
temperature: temperatures.length > 0 ? Math.max(...temperatures) : null,
};
});
}
function mergeDiskRecords(
loadInfoDisks: SynologyDiskRecord[],
smartInfoDisks: SynologyDiskRecord[],
): SynologyDiskRecord[] {
const diskMap = new Map<string, SynologyDiskRecord>();
for (const disk of loadInfoDisks) {
diskMap.set(disk.identifier, disk);
}
for (const disk of smartInfoDisks) {
const existingDisk = diskMap.get(disk.identifier);
diskMap.set(disk.identifier, {
identifier: disk.identifier,
name: existingDisk?.name ?? disk.name,
status: disk.status ?? existingDisk?.status,
temperature: disk.temperature ?? existingDisk?.temperature ?? null,
volumeName: existingDisk?.volumeName ?? disk.volumeName,
});
}
return [...diskMap.values()];
}
function diskBelongsToVolume(disk: SynologyDiskRecord, volumeName: string): boolean {
if (!disk.volumeName) {
return false;
}
return (
disk.volumeName === volumeName ||
disk.volumeName.endsWith(volumeName) ||
volumeName.endsWith(disk.volumeName) ||
disk.volumeName.includes(volumeName) ||
volumeName.includes(disk.volumeName)
);
}
const UNHEALTHY_STATUS_TOKENS = ["critical", "error", "crash", "failed", "fail", "degraded", "warning"] as const;
function getStatusSeverityIndex(status: string): number {
const normalizedStatus = status.toLowerCase();
const index = UNHEALTHY_STATUS_TOKENS.findIndex((token) => normalizedStatus.includes(token));
return index === -1 ? UNHEALTHY_STATUS_TOKENS.length : index;
}
function pickWorstDiskStatus(disks: SynologyDiskRecord[]): string {
const statuses = disks
.map((disk) => disk.status)
.filter((status): status is string => status !== undefined && status.length > 0);
if (statuses.length === 0) {
return "normal";
}
return statuses.reduce((worstStatus, currentStatus) =>
getStatusSeverityIndex(currentStatus) < getStatusSeverityIndex(worstStatus) ? currentStatus : worstStatus,
);
}
function isHealthyStatus(status: string | undefined): boolean {
if (!status) {
return true;
}
const normalizedStatus = status.toLowerCase();
return !["crash", "degraded", "fail", "failed", "warning", "error", "critical"].some((unhealthyToken) =>
normalizedStatus.includes(unhealthyToken),
);
}
function parseNumericValue(value: number | string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
@@ -0,0 +1,191 @@
import { z } from "zod";
export const REQUEST_TIMEOUT_MS = 5000;
export const AUTH_API_NAME = "SYNO.API.Auth";
export const INFO_API_NAME = "SYNO.API.Info";
export const SESSION_ERROR_CODES = new Set([106, 107, 119]);
export const synologyEnvelopeSchema = z.object({
success: z.boolean(),
data: z.unknown().optional(),
error: z
.object({
code: z.number(),
})
.optional(),
});
export const synologyApiDefinitionSchema = z.object({
path: z.string(),
maxVersion: z.number(),
});
export const synologyApiInfoResponseSchema = synologyEnvelopeSchema.extend({
data: z.record(z.string(), synologyApiDefinitionSchema).optional(),
});
export const synologyAuthResponseSchema = synologyEnvelopeSchema.extend({
data: z
.object({
sid: z.string().optional(),
})
.optional(),
});
const numericString = z.union([z.number(), z.string()]);
export const synologySystemInfoDataSchema = z
.object({
up_time: z.string().optional(),
uptime: z.number().optional(),
version_string: z.string().optional(),
firmware_ver: z.string().optional(),
version: z.union([z.string(), z.number()]).optional(),
model: z.string().optional(),
sys_temp: numericString.optional(),
temperature: numericString.optional(),
})
.passthrough();
export const synologyVolumeInfoSchema = z.object({
name: z.string(),
used_size: numericString.optional(),
total_size: numericString.optional(),
status: z.string().optional(),
vol_path: z.string().optional(),
});
export const synologyLegacyStorageDataSchema = z.object({
vol_info: z.array(synologyVolumeInfoSchema).optional(),
});
export const synologyStorageV2VolumeSchema = z
.object({
id: z.string().optional(),
vol_path: z.string().optional(),
display_name: z.string().optional(),
size: z
.object({
total: z.union([z.number(), z.string()]).optional(),
used: z.union([z.number(), z.string()]).optional(),
})
.optional(),
status: z.string().optional(),
})
.passthrough();
export const synologyStorageV2DataSchema = z.object({
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
});
export const synologyUtilizationCpuSchema = z.object({
user_load: numericString.optional(),
system_load: numericString.optional(),
other_load: numericString.optional(),
"1min_load": numericString.optional(),
"5min_load": numericString.optional(),
"15min_load": numericString.optional(),
});
export const synologyUtilizationMemorySchema = z.object({
real_usage: numericString.optional(),
avail_real: numericString.optional(),
cached: numericString.optional(),
total_real: numericString.optional(),
});
export const synologyUtilizationNetworkSchema = z.object({
device: z.string(),
rx: numericString.optional(),
tx: numericString.optional(),
});
export const synologyUtilizationDataSchema = z.object({
cpu: synologyUtilizationCpuSchema.optional(),
memory: synologyUtilizationMemorySchema.optional(),
network: z.array(synologyUtilizationNetworkSchema).optional(),
});
export const synologyStorageDiskSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
display_name: z.string().optional(),
status: z.string().optional(),
temp: numericString.optional(),
temperature: numericString.optional(),
smart_status: z.string().optional(),
volume_id: z.string().optional(),
vol_path: z.string().optional(),
})
.passthrough();
export const synologyStorageLoadInfoDataSchema = z
.object({
disks: z.array(synologyStorageDiskSchema).optional(),
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
overview_data: z
.object({
volumes: z.array(synologyVolumeInfoSchema).optional(),
})
.optional(),
})
.passthrough();
export const synologySmartDiskSchema = z
.object({
id: z.string().optional(),
disk_id: z.string().optional(),
name: z.string().optional(),
status: z.string().optional(),
temp: numericString.optional(),
temperature: numericString.optional(),
overall_status: z.string().optional(),
})
.passthrough();
export const synologySmartInfoDataSchema = z
.object({
disks: z.array(synologySmartDiskSchema).optional(),
items: z.array(synologySmartDiskSchema).optional(),
})
.passthrough();
export const synologySystemStatusDataSchema = z
.object({
reboot_required: z.boolean().optional(),
system_need_repair: z.boolean().optional(),
})
.passthrough();
export const synologyUpgradeStatusDataSchema = z
.object({
reboot_required: z.boolean().optional(),
upgrade_ready: z.boolean().optional(),
})
.passthrough();
export const synologyUpgradeCheckDataSchema = z
.object({
available: z.boolean().optional(),
update_count: z.number().optional(),
})
.passthrough();
export type SynologyVolumeRecord = {
name: string;
displayName?: string;
usedBytes: number;
totalBytes: number;
status: string | undefined;
};
export type SynologyDiskRecord = {
identifier: string;
name: string;
status: string | undefined;
temperature: number | null;
volumeName: string | undefined;
};
@@ -0,0 +1,587 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { z } from "zod";
vi.hoisted(() => {
process.env.CI = "true";
process.env.NODE_ENV = "test";
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
});
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("../../base/session-store", () => ({
createSessionStore: () => ({
getAsync: vi.fn().mockResolvedValue(null),
setAsync: vi.fn().mockResolvedValue(undefined),
clearAsync: vi.fn().mockResolvedValue(undefined),
}),
}));
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { IntegrationResponseError } from "../../base/errors/http/integration-response-error";
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);
const createIntegration = (secrets: IntegrationSecret[] = []) =>
new SynologyIntegration({
id: "test-synology",
name: "Test Synology",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: secrets.length
? secrets
: [
{ kind: "username", value: "homarr" },
{ kind: "password", value: "secret-password" },
],
});
const apiDefinitions = {
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
"SYNO.API.Info": { path: "query.cgi", maxVersion: 1 },
"SYNO.Core.System": { path: "entry.cgi", maxVersion: 3 },
"SYNO.Core.System.Utilization": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Storage.CGI.Storage": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Storage.CGI.Smart": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Core.System.Status": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Core.Upgrade": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Core.Upgrade.Server": { path: "entry.cgi", maxVersion: 1 },
};
const systemInfoResponse = {
success: true as const,
data: {
up_time: "48:30:15",
version_string: "DSM 7.3-12345",
model: "DS920+",
sys_temp: 42,
},
};
const utilizationResponse = {
success: true,
data: {
cpu: {
user_load: 12,
system_load: 8,
"1min_load": 10,
"5min_load": 14,
"15min_load": 9,
},
memory: {
real_usage: 35,
total_real: 8 * 1024 * 1024,
avail_real: Math.round(8 * 1024 * 1024 * 0.65),
cached: 900_000,
},
network: [
{ device: "total", rx: 4096, tx: 8192 },
{ device: "eth0", rx: 4096, tx: 8192 },
],
},
};
const storageV2Response = {
success: true,
data: {
volumes: [
{
id: "volume_1",
display_name: "Data Volume 1",
size: { total: 10_000_000_000, used: 4_000_000_000 },
status: "normal",
},
{
id: "volume_2",
display_name: "Data Volume 2",
size: { total: 20_000_000_000, used: 5_000_000_000 },
status: "degraded",
},
],
},
};
const storageLoadInfoResponse = {
success: true,
data: {
disks: [
{
id: "sda",
display_name: "Drive 1",
temp: 38,
status: "normal",
volume_id: "volume_1",
},
{
id: "sdb",
display_name: "Drive 2",
temp: 44,
status: "normal",
volume_id: "volume_2",
},
],
},
};
const smartInfoResponse = {
success: true,
data: {
disks: [
{
disk_id: "sda",
name: "Drive 1",
overall_status: "normal",
temp: 39,
},
],
},
};
const systemStatusResponse = {
success: true,
data: {
reboot_required: true,
},
};
const upgradeStatusResponse = {
success: true,
data: {
reboot_required: false,
},
};
const upgradeCheckResponse = {
success: true,
data: {
available: true,
update_count: 2,
},
};
const toUrlString = (url: unknown): string => String(url);
const getApiFromUrl = (url: string) => {
const parsedUrl = new URL(url);
return parsedUrl.searchParams.get("api");
};
const getMethodFromUrl = (url: string) => {
const parsedUrl = new URL(url);
return parsedUrl.searchParams.get("method");
};
const getTypeFromUrl = (url: string) => {
const parsedUrl = new URL(url);
return parsedUrl.searchParams.get("type");
};
const mockSuccessfulFlow = (options?: {
skipStorageV2?: boolean;
skipOptional?: boolean;
systemInfo?: {
success: boolean;
data: Record<string, unknown>;
};
utilization?: { success: boolean; data: z.input<typeof synologyUtilizationDataSchema> };
storageV2?: { success: boolean; data: z.input<typeof synologyStorageV2DataSchema> };
storageLoadInfo?: typeof storageLoadInfoResponse;
}) => {
mockFetch.mockImplementation((url) => {
const urlString = toUrlString(url);
const apiName = getApiFromUrl(urlString);
const method = getMethodFromUrl(urlString);
const type = getTypeFromUrl(urlString);
if (apiName === "SYNO.API.Info" && method === "query") {
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.API.Auth" && method === "login") {
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { sid: "test-session-id" } }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": "id=test-session-id; Path=/; HttpOnly",
},
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage_v2") {
if (options?.skipStorageV2) {
return Promise.resolve(
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>;
}
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage") {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
data: {
vol_info: [
{
name: "volume_1",
used_size: 4_000_000_000,
total_size: 10_000_000_000,
status: "normal",
},
],
},
}),
{ status: 200 },
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System" && method === "info" && !type) {
return Promise.resolve(
new Response(JSON.stringify(options?.systemInfo ?? systemInfoResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System.Utilization" && method === "get") {
return Promise.resolve(
new Response(JSON.stringify(options?.utilization ?? utilizationResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (options?.skipOptional) {
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 105 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Storage.CGI.Storage" && method === "load_info") {
return Promise.resolve(
new Response(JSON.stringify(options?.storageLoadInfo ?? storageLoadInfoResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Storage.CGI.Smart" && method === "get_smart_info") {
return Promise.resolve(new Response(JSON.stringify(smartInfoResponse), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (apiName === "SYNO.Core.System.Status" && method === "get") {
return Promise.resolve(
new Response(JSON.stringify(systemStatusResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.Upgrade" && method === "status") {
return Promise.resolve(
new Response(JSON.stringify(upgradeStatusResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.Upgrade.Server" && method === "check") {
return Promise.resolve(
new Response(JSON.stringify(upgradeCheckResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 102 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
});
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("SynologyIntegration.getSystemInfoAsync", () => {
test("maps all supported health monitoring fields", async () => {
mockSuccessfulFlow();
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.version).toBe("DSM 7.3-12345");
expect(result.cpuModelName).toBe("DS920+");
expect(result.cpuUtilization).toBe(20);
expect(result.memUsedInBytes).toBe(Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
expect(result.memAvailableInBytes).toBe(8 * 1024 * 1024 * 1024 - Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
expect(result.uptime).toBe(48 * 3600 + 30 * 60 + 15);
expect(result.cpuTemp).toBe(42);
expect(result.network).toEqual({ up: 8192, down: 4096 });
expect(result.loadAverage).toEqual({ "1min": 10, "5min": 14, "15min": 9 });
expect(result.rebootRequired).toBe(true);
expect(result.availablePkgUpdates).toBe(2);
expect(result.gpu).toEqual([]);
expect(result.fileSystem).toHaveLength(2);
expect(result.fileSystem[0]).toEqual({
deviceName: "volume_1",
used: "4000000000",
available: "6000000000",
percentage: 40,
});
expect(result.smart).toEqual([
{
deviceName: "volume_1",
healthy: true,
overallStatus: "normal",
temperature: 39,
},
{
deviceName: "volume_2",
healthy: false,
overallStatus: "degraded",
temperature: 44,
},
]);
});
test("picks worst disk status when volume status is missing", async () => {
mockSuccessfulFlow({
storageV2: {
success: true,
data: {
volumes: [
{
id: "volume_1",
display_name: "Data Volume 1",
size: { total: 10_000_000_000, used: 4_000_000_000 },
},
],
},
},
storageLoadInfo: {
success: true,
data: {
disks: [
{
id: "sda",
display_name: "Drive 1",
temp: 38,
status: "normal",
volume_id: "volume_1",
},
{
id: "sdb",
display_name: "Drive 2",
temp: 40,
status: "degraded",
volume_id: "volume_1",
},
],
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.smart).toEqual([
{
deviceName: "volume_1",
healthy: false,
overallStatus: "degraded",
temperature: 40,
},
]);
});
test("falls back to legacy storage info when storage_v2 is unavailable", async () => {
mockSuccessfulFlow({ skipStorageV2: true });
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toHaveLength(1);
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
});
test("continues when optional tier-2 calls fail", async () => {
mockSuccessfulFlow({ skipOptional: true });
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.cpuUtilization).toBe(20);
expect(result.rebootRequired).toBe(false);
expect(result.availablePkgUpdates).toBe(0);
expect(result.smart.every((entry) => entry.temperature === null)).toBe(true);
});
test("converts memory values from KiB to bytes", async () => {
mockSuccessfulFlow({
utilization: {
...utilizationResponse,
data: {
...utilizationResponse.data,
memory: {
real_usage: 17,
total_real: 16_351_532,
avail_real: 426_568,
},
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
const totalBytes = 16_351_532 * 1024;
expect(result.memUsedInBytes).toBe(Math.round((totalBytes * 17) / 100));
expect(result.memAvailableInBytes).toBe(totalBytes - Math.round((totalBytes * 17) / 100));
});
test("caps cpu utilization at 100 when user and system load exceed 100", async () => {
mockSuccessfulFlow({
utilization: {
...utilizationResponse,
data: {
...utilizationResponse.data,
cpu: {
...utilizationResponse.data.cpu,
user_load: 80,
system_load: 30,
},
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.cpuUtilization).toBe(100);
});
test("returns undefined cpu temperature when sensor is absent", async () => {
mockSuccessfulFlow({
systemInfo: {
success: true,
data: {
up_time: "01:00:00",
version_string: "DSM 7.3-12345",
model: "DS920+",
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.cpuTemp).toBeUndefined();
});
});
describe("SynologyIntegration.listStorageVolumesAsync", () => {
test("returns auto-detected volume names", async () => {
mockSuccessfulFlow();
const integration = createIntegration();
const volumes = await integration.listStorageVolumesAsync();
expect(volumes).toEqual([
{ value: "test-synology:volume_1", label: "Data Volume 1 (Test Synology)" },
{ value: "test-synology:volume_2", label: "Data Volume 2 (Test Synology)" },
]);
});
});
describe("SynologyIntegration authentication", () => {
test("rejects two-factor authentication during login", async () => {
mockFetch.mockImplementation((url) => {
const urlString = toUrlString(url);
const apiName = getApiFromUrl(urlString);
const method = getMethodFromUrl(urlString);
if (apiName === "SYNO.API.Info" && method === "query") {
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.API.Auth" && method === "login") {
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
});
test("does not leak credentials in login error URLs", async () => {
mockFetch.mockImplementation((url, init) => {
const urlString = toUrlString(url);
const apiName = getApiFromUrl(urlString);
const method = getMethodFromUrl(urlString);
if (apiName === "SYNO.API.Info" && method === "query") {
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.API.Auth" && method === "login") {
expect(init?.method).toBe("POST");
expect(init?.body).toContain("account=homarr");
expect(init?.body).toContain("passwd=secret-password");
expect(urlString).not.toContain("account=");
expect(urlString).not.toContain("passwd=");
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
try {
await integration.getSystemInfoAsync();
expect.unreachable("Expected login to fail");
} catch (error) {
expect(error).toBeInstanceOf(IntegrationResponseError);
const responseError = (error as IntegrationResponseError).cause;
expect(responseError).toBeInstanceOf(ResponseError);
expect(responseError.url).not.toContain("secret-password");
expect(responseError.url).not.toContain("passwd=");
expect(responseError.url).not.toContain("account=homarr");
}
});
});
@@ -0,0 +1,163 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
TraefikDashboardData,
TraefikProtocolSummary,
TraefikResource,
TraefikResourceStatus,
TraefikResourceSummary,
} from "./traefik-types";
import { traefikResourcesSchema, traefikVersionSchema } from "./traefik-types";
type ResourcePath =
| "/api/http/routers"
| "/api/http/services"
| "/api/http/middlewares"
| "/api/tcp/routers"
| "/api/tcp/services"
| "/api/tcp/middlewares"
| "/api/udp/routers"
| "/api/udp/services"
| "/api/entrypoints";
export class TraefikIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() });
if (!response.ok) {
throw new ResponseError(response);
}
traefikVersionSchema.parse(await response.json());
return { success: true };
}
public async getDashboardDataAsync(): Promise<TraefikDashboardData> {
const [
versionResult,
entryPointsResult,
httpRoutersResult,
httpServicesResult,
httpMiddlewaresResult,
tcpRoutersResult,
tcpServicesResult,
tcpMiddlewaresResult,
udpRoutersResult,
udpServicesResult,
] = await Promise.allSettled([
this.getVersionAsync(),
this.getResourcesAsync("/api/entrypoints"),
this.getResourcesAsync("/api/http/routers"),
this.getResourcesAsync("/api/http/services"),
this.getResourcesAsync("/api/http/middlewares"),
this.getResourcesAsync("/api/tcp/routers"),
this.getResourcesAsync("/api/tcp/services"),
this.getResourcesAsync("/api/tcp/middlewares"),
this.getResourcesAsync("/api/udp/routers"),
this.getResourcesAsync("/api/udp/services"),
]);
const version = versionResult.status === "fulfilled" ? versionResult.value : null;
const entryPoints = entryPointsResult.status === "fulfilled" ? entryPointsResult.value : [];
const httpRouters = httpRoutersResult.status === "fulfilled" ? httpRoutersResult.value : [];
const httpServices = httpServicesResult.status === "fulfilled" ? httpServicesResult.value : [];
const httpMiddlewares = httpMiddlewaresResult.status === "fulfilled" ? httpMiddlewaresResult.value : [];
const tcpRouters = tcpRoutersResult.status === "fulfilled" ? tcpRoutersResult.value : [];
const tcpServices = tcpServicesResult.status === "fulfilled" ? tcpServicesResult.value : [];
const tcpMiddlewares = tcpMiddlewaresResult.status === "fulfilled" ? tcpMiddlewaresResult.value : [];
const udpRouters = udpRoutersResult.status === "fulfilled" ? udpRoutersResult.value : [];
const udpServices = udpServicesResult.status === "fulfilled" ? udpServicesResult.value : [];
return {
version,
entryPoints: entryPoints.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`),
http: this.createProtocolSummary(httpRouters, httpServices, httpMiddlewares),
tcp: this.createProtocolSummary(tcpRouters, tcpServices, tcpMiddlewares),
udp: {
routers: this.summarizeResources(udpRouters),
services: this.summarizeResources(udpServices),
},
};
}
private async getVersionAsync() {
const response = await this.fetchOkAsync("/api/version");
const version = traefikVersionSchema.parse(await response.json());
return version.Version ?? null;
}
private async getResourcesAsync(path: ResourcePath) {
const response = await this.fetchOkAsync(path);
return traefikResourcesSchema.parse(await response.json());
}
private createProtocolSummary(
routers: TraefikResource[],
services: TraefikResource[],
middlewares: TraefikResource[],
): TraefikProtocolSummary {
return {
routers: this.summarizeResources(routers),
services: this.summarizeResources(services),
middlewares: this.summarizeResources(middlewares),
};
}
private summarizeResources(resources: TraefikResource[]): TraefikResourceSummary {
return resources.reduce<TraefikResourceSummary>(
(summary, resource) => {
const status = this.normalizeStatus(resource.status);
summary.total += 1;
if (status === "enabled") summary.enabled += 1;
if (status === "warning") summary.warnings += 1;
if (status === "error") summary.errors += 1;
return summary;
},
{ total: 0, enabled: 0, warnings: 0, errors: 0 },
);
}
private normalizeStatus(status: string | undefined): TraefikResourceStatus {
if (!status) return "unknown";
const normalized = status.toLowerCase();
if (normalized === "enabled" || normalized === "disabled" || normalized === "warning" || normalized === "error") {
return normalized;
}
return "unknown";
}
private async fetchOkAsync(path: ResourcePath | "/api/version") {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
timeout: 10_000,
});
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
private getAuthHeaders(): Record<string, string> {
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
return { Authorization: `Basic ${credentials}` };
}
if (this.hasSecretValue("apiKey")) {
return { Authorization: `Bearer ${this.getSecretValue("apiKey")}` };
}
return {};
}
}
@@ -0,0 +1,43 @@
import { z } from "zod";
export const traefikResourceStatusSchema = z.enum(["enabled", "disabled", "warning", "error", "unknown"]);
const traefikResourceSchema = z
.object({
name: z.string().optional(),
provider: z.string().optional(),
status: z.string().optional(),
})
.passthrough();
export const traefikVersionSchema = z
.object({
Version: z.string().optional(),
})
.passthrough();
export const traefikResourcesSchema = z.array(traefikResourceSchema);
export interface TraefikResourceSummary {
total: number;
enabled: number;
warnings: number;
errors: number;
}
export interface TraefikProtocolSummary {
routers: TraefikResourceSummary;
services: TraefikResourceSummary;
middlewares: TraefikResourceSummary;
}
export interface TraefikDashboardData {
version: string | null;
entryPoints: string[];
http: TraefikProtocolSummary;
tcp: TraefikProtocolSummary;
udp: Omit<TraefikProtocolSummary, "middlewares">;
}
export type TraefikResource = z.infer<typeof traefikResourcesSchema>[number];
export type TraefikResourceStatus = z.infer<typeof traefikResourceStatusSchema>;
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff. Show more